//! 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::collections::HashMap; use std::fs; use std::io::Write; use top_drives::test_utils::{make_car, make_record}; use top_drives::{ compare_cars, compute_car_counts, empty_my_data, escape_csv_field, load_my_cars_data, translate_track_name, translate_track_type, AnalysisContext, BestCarRecord, Car, }; // ======================================================================= // Integration: AnalysisContext → CarCounts pipeline // ======================================================================= #[test] fn test_full_pipeline_empty_input() { let ctx = AnalysisContext::new(); let car_list: Vec = vec![]; let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new()); 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], empty_my_data(), HashMap::new()); assert_eq!(counts.by_rid.len(), 1); let (rec, count) = counts.by_rid.get(&("Ferrari_F40".to_string(), "332".to_string())).unwrap(); assert_eq!(*count, 20); assert_eq!(rec.time, 88.5); // Verify guid_to_rid mapping let guid_entries = counts.guid_to_rid.get("guid_f40").unwrap(); assert_eq!(guid_entries.len(), 1); assert!(guid_entries.contains(&("Ferrari_F40".to_string(), "332".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, empty_my_data(), HashMap::new()); assert_eq!(counts.by_rid.len(), 3); let (_, count_a) = counts.by_rid.get(&("Car_A".to_string(), "332".to_string())).unwrap(); assert_eq!(*count_a, 5); let (_, count_b) = counts.by_rid.get(&("Car_B".to_string(), "332".to_string())).unwrap(); assert_eq!(*count_b, 12); let (_, count_c) = counts.by_rid.get(&("Car_C".to_string(), "332".to_string())).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(), normalized_config: "332".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,FinalBuild,Count").unwrap(); for (rec, count) in &entries { writeln!( file, "{},{},{},{},{},{}", rec.rq, escape_csv_field(&rec.brand), escape_csv_field(&rec.only_name), rec.year, rec.normalized_config, 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,FinalBuild,Count"); assert_eq!(lines[1], "85,BMW,M3,2023,332,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, empty_my_data(), HashMap::new()); // 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".to_string(), "332".to_string())).unwrap(); assert!(owned_rec.owned); let (unowned_rec, _) = counts.by_rid.get(&("Unowned_Car".to_string(), "332".to_string())).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, "engineMajor": 3, "weightMajor": 2, "chassisMajor": 3 }, { "cardId": "guid_a", "locked": true, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 }, { "cardId": "guid_b", "locked": false, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 }, { "cardId": "guid_c", "locked": true, "engineMajor": 1, "weightMajor": 1, "chassisMajor": 1 } ], "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 (set deduplicates duplicate guids) assert_eq!(data.owned.len(), 2); assert!(data.owned.contains("guid_a")); assert!(data.owned.contains("guid_c")); assert!(!data.owned.contains("guid_b")); // copy_count tracks duplicates: guid_a has two copies, guid_c one assert_eq!(data.copy_count.get("guid_a"), Some(&2)); assert_eq!(data.copy_count.get("guid_c"), Some(&1)); assert!(!data.copy_count.contains_key("guid_b")); // owned_configs collects one entry per copy with complete tune data assert_eq!(data.owned_configs.get("guid_a"), Some(&vec!["323".to_string(), "233".to_string()])); assert_eq!(data.owned_configs.get("guid_c"), Some(&vec!["111".to_string()])); // Unlocked card should not be in owned_configs assert!(!data.owned_configs.contains_key("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_incomplete_tune_data() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("my_cars.json"); let json = r#"{ "playerDeck": [ { "cardId": "guid_full", "locked": true, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 }, { "cardId": "guid_partial", "locked": true, "engineMajor": 2, "weightMajor": 3 }, { "cardId": "guid_empty", "locked": true } ] }"#; fs::write(&path, json).unwrap(); let data = load_my_cars_data(path.to_str().unwrap()).unwrap(); // All locked cards are owned assert_eq!(data.owned.len(), 3); // copy_count counts every locked card, even without complete tune data assert_eq!(data.copy_count.get("guid_full"), Some(&1)); assert_eq!(data.copy_count.get("guid_partial"), Some(&1)); assert_eq!(data.copy_count.get("guid_empty"), Some(&1)); // Only the card with complete tune data has a config entry assert_eq!(data.owned_configs.len(), 1); assert_eq!(data.owned_configs.get("guid_full"), Some(&vec!["233".to_string()])); assert!(!data.owned_configs.contains_key("guid_partial")); assert!(!data.owned_configs.contains_key("guid_empty")); } #[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); }