top_drives/tests/integration_tests.rs
Jan-Peter von Hunnius 67a79f190a 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)
2026-05-08 20:21:46 +02:00

334 lines
9.7 KiB
Rust

//! 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,
};
use top_drives::test_utils::{make_record, make_car};
// =======================================================================
// 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);
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]);
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);
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);
// 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);
}