refactor: modular architecture with unified error handling and DashMap concurrency
- Break monolithic records.rs into commands/, output/, parsing/ modules - Add lib.rs with shared types (AppError, Car, BestCarRecord, etc.) - Replace Arc<Mutex<HashMap>> with DashMap for concurrent access - Add unified AppError enum with thiserror - Add configurable CLI paths (--my-cars, --cars-db, --times-dir, --tracks-db) - Add serde deserialization for Car struct - Add CSV escaping for fields with commas/quotes/newlines - Fix track name splitting: split_once -> rsplit_once for _a handling - Add integration tests, proptest property-based tests, and unit tests - Add rustfmt.toml configuration - Update documentation (API, ARCHITECTURE, DATA_STRUCTURES, README)
This commit is contained in:
parent
9d57b18a4e
commit
a9a46ee551
24 changed files with 3541 additions and 1470 deletions
362
tests/integration_tests.rs
Normal file
362
tests/integration_tests.rs
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
//! Integration tests for the Top Drives Records Analyzer.
|
||||
//!
|
||||
//! These tests exercise the full analysis pipeline with fixture data,
|
||||
//! verifying end-to-end correctness from parsing through CSV output.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
|
||||
use top_drives::{
|
||||
AnalysisContext, BestCarRecord, Car,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Integration: AnalysisContext → CarCounts pipeline
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
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();
|
||||
|
||||
assert!(counts.by_rid.is_empty());
|
||||
assert!(counts.guid_to_rid.is_empty());
|
||||
assert!(counts.car_map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_pipeline_single_car_multiple_tracks() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let rec = make_record("Ferrari_F40", 95, 88.5, "guid_f40");
|
||||
|
||||
// Simulate the car being best on 20 tracks
|
||||
for i in 0..20 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 95, "Dry".to_string()),
|
||||
vec![rec.clone()],
|
||||
);
|
||||
}
|
||||
|
||||
let car = make_car("Ferrari_F40", 95, "guid_f40");
|
||||
let counts = compute_car_counts(&ctx, vec![car]).unwrap();
|
||||
|
||||
assert_eq!(counts.by_rid.len(), 1);
|
||||
let (rec, count) = counts.by_rid.get("Ferrari_F40").unwrap();
|
||||
assert_eq!(*count, 20);
|
||||
assert_eq!(rec.time, 88.5);
|
||||
|
||||
// Verify guid_to_rid mapping
|
||||
assert_eq!(
|
||||
counts.guid_to_rid.get("guid_f40"),
|
||||
Some(&"Ferrari_F40".to_string())
|
||||
);
|
||||
|
||||
// Verify car_map
|
||||
assert!(counts.car_map.contains_key("guid_f40"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_pipeline_multiple_cars_different_rq() {
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
// Car A: RQ 80, best on 5 tracks
|
||||
let rec_a = make_record("Car_A", 80, 100.0, "guid_a");
|
||||
for i in 0..5 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 80, "Dry".to_string()),
|
||||
vec![rec_a.clone()],
|
||||
);
|
||||
}
|
||||
|
||||
// Car B: RQ 90, best on 12 tracks
|
||||
let rec_b = make_record("Car_B", 90, 95.0, "guid_b");
|
||||
for i in 0..12 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("TrackB{i}"), 90, "Dry".to_string()),
|
||||
vec![rec_b.clone()],
|
||||
);
|
||||
}
|
||||
|
||||
// Car C: RQ 100, best on 8 tracks
|
||||
let rec_c = make_record("Car_C", 100, 85.0, "guid_c");
|
||||
for i in 0..8 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("TrackC{i}"), 100, "Dry".to_string()),
|
||||
vec![rec_c.clone()],
|
||||
);
|
||||
}
|
||||
|
||||
let car_list = vec![
|
||||
make_car("Car_A", 80, "guid_a"),
|
||||
make_car("Car_B", 90, "guid_b"),
|
||||
make_car("Car_C", 100, "guid_c"),
|
||||
];
|
||||
|
||||
let counts = compute_car_counts(&ctx, car_list).unwrap();
|
||||
|
||||
assert_eq!(counts.by_rid.len(), 3);
|
||||
|
||||
let (_, count_a) = counts.by_rid.get("Car_A").unwrap();
|
||||
assert_eq!(*count_a, 5);
|
||||
|
||||
let (_, count_b) = counts.by_rid.get("Car_B").unwrap();
|
||||
assert_eq!(*count_b, 12);
|
||||
|
||||
let (_, count_c) = counts.by_rid.get("Car_C").unwrap();
|
||||
assert_eq!(*count_c, 8);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Integration: CSV output correctness
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_csv_output_field_ordering() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("output.csv");
|
||||
let path_str = path.to_str().unwrap();
|
||||
|
||||
let rec = BestCarRecord {
|
||||
car_rid: "Test".to_string(),
|
||||
brand: "BMW".to_string(),
|
||||
only_name: "M3".to_string(),
|
||||
year: 2023,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.0,
|
||||
owned: true,
|
||||
prize: false,
|
||||
guid: "guid_test".to_string(),
|
||||
};
|
||||
let entries = vec![(rec, 7)];
|
||||
|
||||
// Write with the standard format
|
||||
{
|
||||
let mut file = fs::File::create(path_str).unwrap();
|
||||
writeln!(file, "RQ,Brand,Model,Year,Count").unwrap();
|
||||
for (rec, count) in &entries {
|
||||
writeln!(
|
||||
file,
|
||||
"{},{},{},{},{}",
|
||||
rec.rq,
|
||||
escape_csv_field(&rec.brand),
|
||||
escape_csv_field(&rec.only_name),
|
||||
rec.year,
|
||||
count
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines[0], "RQ,Brand,Model,Year,Count");
|
||||
assert_eq!(lines[1], "85,BMW,M3,2023,7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csv_escaping_in_output() {
|
||||
// Verify that fields containing commas and quotes are properly escaped
|
||||
let field = escape_csv_field("Alfa Romeo, Inc.");
|
||||
assert_eq!(field, "\"Alfa Romeo, Inc.\"");
|
||||
|
||||
let field = escape_csv_field("Model \"Sport\"");
|
||||
assert_eq!(field, "\"Model \"\"Sport\"\"\"");
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Integration: Track name translation pipeline
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_track_translation_pipeline() {
|
||||
// Verify the full chain: raw track code → translated name
|
||||
let raw_codes = vec![
|
||||
("laguna", "Laguna Seca"),
|
||||
("mnCity", "Monaco Streets"),
|
||||
("testBowl", "Test Bowl"),
|
||||
("drag100", "0-100mph"),
|
||||
("northloop", "North Loop"),
|
||||
];
|
||||
|
||||
for (code, expected) in raw_codes {
|
||||
assert_eq!(translate_track_name(code), expected);
|
||||
}
|
||||
|
||||
// Unknown codes should pass through unchanged
|
||||
assert_eq!(translate_track_name("futureTrack"), "futureTrack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_condition_translation_pipeline() {
|
||||
let conditions = vec![
|
||||
("00", "Dry"),
|
||||
("01", "Wet"),
|
||||
("10", "Dirt"),
|
||||
("11", "Dirt Wet"),
|
||||
("60", "Snow"),
|
||||
];
|
||||
|
||||
for (code, expected) in conditions {
|
||||
assert_eq!(translate_track_type(code), expected);
|
||||
}
|
||||
|
||||
assert_eq!(translate_track_type("99"), "99");
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Integration: Ownership filtering
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_ownership_filtering_in_counts() {
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
// Two cars: one owned, one not
|
||||
let mut rec_owned = make_record("Owned_Car", 85, 100.0, "guid_owned");
|
||||
rec_owned.owned = true;
|
||||
let rec_unowned = make_record("Unowned_Car", 90, 95.0, "guid_unowned");
|
||||
|
||||
for i in 0..10 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 85, "Dry".to_string()),
|
||||
vec![rec_owned.clone()],
|
||||
);
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 90, "Dry".to_string()),
|
||||
vec![rec_unowned.clone()],
|
||||
);
|
||||
}
|
||||
|
||||
let car_list = vec![
|
||||
make_car("Owned_Car", 85, "guid_owned"),
|
||||
make_car("Unowned_Car", 90, "guid_unowned"),
|
||||
];
|
||||
|
||||
let counts = compute_car_counts(&ctx, car_list).unwrap();
|
||||
|
||||
// Both should be in by_rid
|
||||
assert_eq!(counts.by_rid.len(), 2);
|
||||
|
||||
// Verify ownership is reflected in the records
|
||||
let (owned_rec, _) = counts.by_rid.get("Owned_Car").unwrap();
|
||||
assert!(owned_rec.owned);
|
||||
|
||||
let (unowned_rec, _) = counts.by_rid.get("Unowned_Car").unwrap();
|
||||
assert!(!unowned_rec.owned);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Integration: load_my_cars_data with fixture file
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_load_my_cars_data_with_fixture() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("my_cars.json");
|
||||
|
||||
let json = r#"{
|
||||
"playerDeck": [
|
||||
{ "cardId": "guid_a", "locked": true },
|
||||
{ "cardId": "guid_b", "locked": false },
|
||||
{ "cardId": "guid_c", "locked": true }
|
||||
],
|
||||
"wishlists": [
|
||||
{ "cards": ["guid_d", "guid_e"] }
|
||||
]
|
||||
}"#;
|
||||
fs::write(&path, json).unwrap();
|
||||
|
||||
let data = load_my_cars_data(path.to_str().unwrap()).unwrap();
|
||||
|
||||
// Only locked cards are owned
|
||||
assert_eq!(data.owned.len(), 2);
|
||||
assert!(data.owned.contains("guid_a"));
|
||||
assert!(data.owned.contains("guid_c"));
|
||||
assert!(!data.owned.contains("guid_b"));
|
||||
|
||||
// Wishlist
|
||||
assert_eq!(data.wishlist.len(), 2);
|
||||
assert!(data.wishlist.contains("guid_d"));
|
||||
assert!(data.wishlist.contains("guid_e"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_my_cars_data_empty_wishlist() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("my_cars.json");
|
||||
|
||||
let json = r#"{
|
||||
"playerDeck": [
|
||||
{ "cardId": "guid_a", "locked": true }
|
||||
]
|
||||
}"#;
|
||||
fs::write(&path, json).unwrap();
|
||||
|
||||
let data = load_my_cars_data(path.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(data.owned.len(), 1);
|
||||
assert!(data.wishlist.is_empty());
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Integration: Sorting consistency
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_sorting_consistency_between_car_and_record() {
|
||||
// compare_cars and compare_records should produce the same relative ordering
|
||||
let car_a = Car {
|
||||
rid: "A".to_string(),
|
||||
brand: "BMW".to_string(),
|
||||
only_name: "M3".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
prize: false,
|
||||
guid: "guid_a".to_string(),
|
||||
};
|
||||
let car_b = Car {
|
||||
rid: "B".to_string(),
|
||||
brand: "Audi".to_string(),
|
||||
only_name: "RS6".to_string(),
|
||||
year: 2021,
|
||||
rq: 90,
|
||||
prize: false,
|
||||
guid: "guid_b".to_string(),
|
||||
};
|
||||
|
||||
// compare_cars sorts by RQ first, so A (85) < B (90)
|
||||
assert_eq!(compare_cars(&car_a, &car_b), std::cmp::Ordering::Less);
|
||||
}
|
||||
169
tests/proptest_tests.rs
Normal file
169
tests/proptest_tests.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
//! Property-based tests using proptest for sorting and comparison functions.
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use top_drives::{compare_cars, Car};
|
||||
|
||||
/// Generate arbitrary Car instances for property testing.
|
||||
fn car_strategy() -> impl Strategy<Value = Car> {
|
||||
(
|
||||
"[a-zA-Z]{2,10}", // rid
|
||||
"[a-zA-Z]{2,15}", // brand
|
||||
"[a-zA-Z0-9 ]{2,20}", // only_name
|
||||
1950u16..2030, // year
|
||||
10u8..113, // rq
|
||||
any::<bool>(), // prize
|
||||
"[a-zA-Z0-9_]{3,20}", // guid
|
||||
)
|
||||
.prop_map(|(rid, brand, only_name, year, rq, prize, guid)| Car {
|
||||
rid,
|
||||
brand,
|
||||
only_name,
|
||||
year,
|
||||
rq,
|
||||
prize,
|
||||
guid,
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
// =======================================================================
|
||||
// compare_cars: antisymmetry property
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn compare_cars_antisymmetric(
|
||||
a in car_strategy(),
|
||||
b in car_strategy(),
|
||||
) {
|
||||
// If compare_cars(a, b) == Less, then compare_cars(b, a) == Greater
|
||||
// If compare_cars(a, b) == Equal, then compare_cars(b, a) == Equal
|
||||
let ab = compare_cars(&a, &b);
|
||||
let ba = compare_cars(&b, &a);
|
||||
assert_eq!(ab.reverse(), ba);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// compare_cars: reflexivity (equal elements compare as Equal)
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn compare_cars_reflexive(a in car_strategy()) {
|
||||
assert_eq!(compare_cars(&a, &a), std::cmp::Ordering::Equal);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// compare_cars: transitivity
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn compare_cars_transitive(
|
||||
a in car_strategy(),
|
||||
b in car_strategy(),
|
||||
c in car_strategy(),
|
||||
) {
|
||||
let ab = compare_cars(&a, &b);
|
||||
let bc = compare_cars(&b, &c);
|
||||
let ac = compare_cars(&a, &c);
|
||||
|
||||
// If a <= b and b <= c, then a <= c
|
||||
if ab != std::cmp::Ordering::Greater && bc != std::cmp::Ordering::Greater {
|
||||
assert_ne!(ac, std::cmp::Ordering::Greater);
|
||||
}
|
||||
// If a >= b and b >= c, then a >= c
|
||||
if ab != std::cmp::Ordering::Less && bc != std::cmp::Ordering::Less {
|
||||
assert_ne!(ac, std::cmp::Ordering::Less);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// compare_cars: RQ is the primary sort key
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn compare_cars_rq_primary(
|
||||
brand_a in "[a-zA-Z]{2,10}",
|
||||
brand_b in "[a-zA-Z]{2,10}",
|
||||
model_a in "[a-zA-Z]{2,10}",
|
||||
model_b in "[a-zA-Z]{2,10}",
|
||||
rq_a in 10u8..112u8,
|
||||
rq_offset in 1u8..=3u8,
|
||||
) {
|
||||
let rq_b = rq_a.saturating_add(rq_offset).min(113);
|
||||
let a = Car {
|
||||
rid: "A".to_string(),
|
||||
brand: brand_a,
|
||||
only_name: model_a,
|
||||
year: 2020,
|
||||
rq: rq_a,
|
||||
prize: false,
|
||||
guid: "guid_a".to_string(),
|
||||
};
|
||||
let b = Car {
|
||||
rid: "B".to_string(),
|
||||
brand: brand_b,
|
||||
only_name: model_b,
|
||||
year: 2021,
|
||||
rq: rq_b,
|
||||
prize: true,
|
||||
guid: "guid_b".to_string(),
|
||||
};
|
||||
|
||||
// Lower RQ always sorts first, regardless of other fields
|
||||
assert_eq!(compare_cars(&a, &b), std::cmp::Ordering::Less);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// compare_cars: brand is the secondary sort key when RQ is equal
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn compare_cars_brand_secondary(
|
||||
brand_a in "[a-zA-Z]{2,10}",
|
||||
model in "[a-zA-Z]{2,10}",
|
||||
rq in 10u8..113u8,
|
||||
) {
|
||||
let mut brand_b = brand_a.clone();
|
||||
brand_b.push('z'); // brand_b > brand_a lexicographically
|
||||
|
||||
let a = Car {
|
||||
rid: "A".to_string(),
|
||||
brand: brand_a,
|
||||
only_name: model,
|
||||
year: 2020,
|
||||
rq,
|
||||
prize: false,
|
||||
guid: "guid_a".to_string(),
|
||||
};
|
||||
let b = Car {
|
||||
rid: "B".to_string(),
|
||||
brand: brand_b,
|
||||
only_name: "Z".to_string(),
|
||||
year: 2021,
|
||||
rq,
|
||||
prize: true,
|
||||
guid: "guid_b".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(compare_cars(&a, &b), std::cmp::Ordering::Less);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// escape_csv_field: no double-escaping
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn escape_csv_no_double_escape(field in "[a-zA-Z0-9 ]{0,50}") {
|
||||
let escaped = top_drives::escape_csv_field(&field);
|
||||
// Simple fields should not be modified
|
||||
assert_eq!(escaped, field);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_csv_with_comma(field in "[a-zA-Z]{1,10}") {
|
||||
let input = format!("{field},test");
|
||||
let escaped = top_drives::escape_csv_field(&input);
|
||||
assert!(escaped.starts_with('"'));
|
||||
assert!(escaped.ends_with('"'));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue