Refactor: extract lib.rs into focused sub-modules, add --output-dir CLI option
- Split monolithic src/lib.rs (~640 lines) into 7 sub-modules: types.rs, context.rs, user_data.rs, translate.rs, http.rs, csv_util.rs, test_utils.rs - Add configurable --output-dir CLI option for CSV output files - Implement atomic file writes (write-to-temp + rename) for all CSVs - Change compute_car_counts() to return CarCounts directly (not Result) - Add test-utils feature gate for shared test helpers - Add HTTP mock tests with mockito (6 tests) - Add proptest tests for compute_car_counts (3 property tests) - Update integration tests to use shared test utilities - Update documentation (API.md, ARCHITECTURE.md, DATA_STRUCTURES.md)
This commit is contained in:
parent
d2c1418e5e
commit
67a79f190a
27 changed files with 1648 additions and 789 deletions
130
tests/http_tests.rs
Normal file
130
tests/http_tests.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
//! Tests for the HTTP file download utility with mockito mock server.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use mockito::{Server, ServerGuard, Mock};
|
||||
use top_drives::get_file;
|
||||
|
||||
/// Helper: create a mock that returns a successful response with the given body.
|
||||
fn mock_success(server: &mut ServerGuard, body: &str) -> Mock {
|
||||
server.mock("GET", "/data.json")
|
||||
.with_status(200)
|
||||
.with_body(body)
|
||||
.create()
|
||||
}
|
||||
|
||||
/// Helper: create a mock that returns the given HTTP status.
|
||||
fn mock_status(server: &mut ServerGuard, status: usize) -> Mock {
|
||||
server.mock("GET", "/data.json")
|
||||
.with_status(status)
|
||||
.create()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_file_success() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = mock_success(&mut server, "hello world");
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out_path = dir.path().join("output.txt");
|
||||
let url = format!("{}/data.json", server.url());
|
||||
|
||||
get_file(&url, out_path.to_str().unwrap()).await.unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&out_path).unwrap(), "hello world");
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_file_empty_response_retries_and_fails() {
|
||||
let mut server = Server::new_async().await;
|
||||
// Return 200 with empty body — triggers retry, then fails
|
||||
let m = server.mock("GET", "/data.json")
|
||||
.with_status(200)
|
||||
.with_body("")
|
||||
.expect(4) // 1 initial + 3 retries
|
||||
.create();
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out_path = dir.path().join("output.txt");
|
||||
let url = format!("{}/data.json", server.url());
|
||||
|
||||
let result = get_file(&url, out_path.to_str().unwrap()).await;
|
||||
assert!(result.is_err(), "Empty response should fail after retries");
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_file_server_error_retries() {
|
||||
let mut server = Server::new_async().await;
|
||||
// Return 500 three times, then 200
|
||||
let m_fail = server.mock("GET", "/data.json")
|
||||
.with_status(500)
|
||||
.expect(3)
|
||||
.create();
|
||||
let m_ok = mock_success(&mut server, "recovered");
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out_path = dir.path().join("output.txt");
|
||||
let url = format!("{}/data.json", server.url());
|
||||
|
||||
get_file(&url, out_path.to_str().unwrap()).await.unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&out_path).unwrap(), "recovered");
|
||||
m_fail.assert();
|
||||
m_ok.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_file_client_error_fails_immediately() {
|
||||
let mut server = Server::new_async().await;
|
||||
// 404 should fail immediately without retries
|
||||
let m = mock_status(&mut server, 404).expect(1).create();
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out_path = dir.path().join("output.txt");
|
||||
let url = format!("{}/data.json", server.url());
|
||||
|
||||
let result = get_file(&url, out_path.to_str().unwrap()).await;
|
||||
assert!(result.is_err(), "404 should fail immediately");
|
||||
let err_msg = format!("{}", result.unwrap_err());
|
||||
assert!(err_msg.contains("404"), "Error should mention HTTP 404: {err_msg}");
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_file_persistent_server_error_fails() {
|
||||
let mut server = Server::new_async().await;
|
||||
// 503 every time — should retry 3 times then fail
|
||||
let m = server.mock("GET", "/data.json")
|
||||
.with_status(503)
|
||||
.expect(4) // 1 initial + 3 retries
|
||||
.create();
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out_path = dir.path().join("output.txt");
|
||||
let url = format!("{}/data.json", server.url());
|
||||
|
||||
let result = get_file(&url, out_path.to_str().unwrap()).await;
|
||||
assert!(result.is_err(), "Persistent 503 should fail after retries");
|
||||
let err_msg = format!("{}", result.unwrap_err());
|
||||
assert!(err_msg.contains("503"), "Error should mention HTTP 503: {err_msg}");
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_file_atomic_write_no_partial_on_error() {
|
||||
let mut server = Server::new_async().await;
|
||||
// Return 404 — immediate failure, no file written
|
||||
let m = mock_status(&mut server, 404).create();
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out_path = dir.path().join("output.txt");
|
||||
let url = format!("{}/data.json", server.url());
|
||||
|
||||
let result = get_file(&url, out_path.to_str().unwrap()).await;
|
||||
assert!(result.is_err());
|
||||
// The output file should NOT exist (atomic write via temp + rename)
|
||||
assert!(!out_path.exists(), "No partial file should be left on failure");
|
||||
m.assert();
|
||||
}
|
||||
|
|
@ -11,35 +11,7 @@ use top_drives::{
|
|||
compare_cars, compute_car_counts, escape_csv_field, load_my_cars_data,
|
||||
translate_track_name, translate_track_type,
|
||||
};
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn make_record(car_rid: &str, rq: u8, time: f64, guid: &str) -> BestCarRecord {
|
||||
BestCarRecord {
|
||||
car_rid: car_rid.to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq,
|
||||
config: "111".to_string(),
|
||||
time,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: guid.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_car(rid: &str, rq: u8, guid: &str) -> Car {
|
||||
Car {
|
||||
rid: rid.to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq,
|
||||
prize: false,
|
||||
guid: guid.to_string(),
|
||||
}
|
||||
}
|
||||
use top_drives::test_utils::{make_record, make_car};
|
||||
|
||||
// =======================================================================
|
||||
// Integration: AnalysisContext → CarCounts pipeline
|
||||
|
|
@ -49,7 +21,7 @@ fn make_car(rid: &str, rq: u8, guid: &str) -> Car {
|
|||
fn test_full_pipeline_empty_input() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let car_list: Vec<Car> = vec![];
|
||||
let counts = compute_car_counts(&ctx, car_list).unwrap();
|
||||
let counts = compute_car_counts(&ctx, car_list);
|
||||
|
||||
assert!(counts.by_rid.is_empty());
|
||||
assert!(counts.guid_to_rid.is_empty());
|
||||
|
|
@ -70,7 +42,7 @@ fn test_full_pipeline_single_car_multiple_tracks() {
|
|||
}
|
||||
|
||||
let car = make_car("Ferrari_F40", 95, "guid_f40");
|
||||
let counts = compute_car_counts(&ctx, vec![car]).unwrap();
|
||||
let counts = compute_car_counts(&ctx, vec![car]);
|
||||
|
||||
assert_eq!(counts.by_rid.len(), 1);
|
||||
let (rec, count) = counts.by_rid.get("Ferrari_F40").unwrap();
|
||||
|
|
@ -124,7 +96,7 @@ fn test_full_pipeline_multiple_cars_different_rq() {
|
|||
make_car("Car_C", 100, "guid_c"),
|
||||
];
|
||||
|
||||
let counts = compute_car_counts(&ctx, car_list).unwrap();
|
||||
let counts = compute_car_counts(&ctx, car_list);
|
||||
|
||||
assert_eq!(counts.by_rid.len(), 3);
|
||||
|
||||
|
|
@ -265,7 +237,7 @@ fn test_ownership_filtering_in_counts() {
|
|||
make_car("Unowned_Car", 90, "guid_unowned"),
|
||||
];
|
||||
|
||||
let counts = compute_car_counts(&ctx, car_list).unwrap();
|
||||
let counts = compute_car_counts(&ctx, car_list);
|
||||
|
||||
// Both should be in by_rid
|
||||
assert_eq!(counts.by_rid.len(), 2);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use top_drives::{compare_cars, Car};
|
||||
use top_drives::{compare_cars, compute_car_counts, AnalysisContext, Car};
|
||||
use top_drives::test_utils::{make_car, make_record};
|
||||
|
||||
/// Generate arbitrary Car instances for property testing.
|
||||
fn car_strategy() -> impl Strategy<Value = Car> {
|
||||
|
|
@ -166,4 +167,82 @@ proptest! {
|
|||
assert!(escaped.starts_with('"'));
|
||||
assert!(escaped.ends_with('"'));
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// compute_car_counts: empty context always yields zero counts
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn compute_car_counts_empty_ctx(
|
||||
cars in proptest::collection::vec(car_strategy(), 0..20)
|
||||
) {
|
||||
let ctx = AnalysisContext::default();
|
||||
let counts = compute_car_counts(&ctx, cars.clone());
|
||||
// No best_cars entries → every car has 0 wins → by_rid is empty
|
||||
assert_eq!(counts.by_rid.len(), 0);
|
||||
// guid_to_rid is also empty since no car won any track
|
||||
assert_eq!(counts.guid_to_rid.len(), 0);
|
||||
// But car_map contains ALL cars from the list
|
||||
assert_eq!(counts.car_map.len(), cars.len());
|
||||
for car in &cars {
|
||||
assert!(counts.car_map.contains_key(&car.guid));
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// compute_car_counts: car with wins has correct count
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn compute_car_counts_reflects_wins(
|
||||
n_tracks in 1usize..30,
|
||||
rid in "[a-zA-Z]{3,8}",
|
||||
guid in "[a-zA-Z0-9_]{3,10}",
|
||||
rq in 10u8..113u8,
|
||||
) {
|
||||
let ctx = AnalysisContext::default();
|
||||
let rec = make_record(&rid, rq, 100.0, &guid);
|
||||
|
||||
// Insert the car as best on n_tracks tracks
|
||||
for i in 0..n_tracks {
|
||||
ctx.best_cars.insert(
|
||||
(format!("track_{i}"), rq, "Dry".to_string()),
|
||||
vec![rec.clone()],
|
||||
);
|
||||
}
|
||||
|
||||
let car_list = vec![make_car(&rid, rq, &guid)];
|
||||
let counts = compute_car_counts(&ctx, car_list);
|
||||
|
||||
let entry = counts.by_rid.get(&rid);
|
||||
assert!(entry.is_some(), "car should appear in by_rid");
|
||||
let (_, count) = entry.unwrap();
|
||||
assert_eq!(*count, n_tracks, "count should match number of tracks");
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// compute_car_counts: ownership flag propagates correctly
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn compute_car_counts_ownership(
|
||||
rid in "[a-zA-Z]{3,8}",
|
||||
guid in "[a-zA-Z0-9_]{3,10}",
|
||||
rq in 10u8..113u8,
|
||||
owned in any::<bool>(),
|
||||
) {
|
||||
let ctx = AnalysisContext::default();
|
||||
let mut rec = make_record(&rid, rq, 100.0, &guid);
|
||||
rec.owned = owned;
|
||||
ctx.best_cars.insert(
|
||||
("track_x".to_string(), rq, "Dry".to_string()),
|
||||
vec![rec],
|
||||
);
|
||||
|
||||
let car_list = vec![make_car(&rid, rq, &guid)];
|
||||
let counts = compute_car_counts(&ctx, car_list);
|
||||
|
||||
let (record, _) = counts.by_rid.get(&rid).unwrap();
|
||||
assert_eq!(record.owned, owned, "owned flag should propagate");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue