From 4ecfc2d14d1fe98d711d256ff31221ab68515ed5 Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Tue, 26 May 2026 08:52:42 +0200 Subject: [PATCH] Fix remove.csv to include wishlist cars not best on any track Previously, wishlist cars that weren't best on any track were silently dropped from remove.csv. Now uses car_map as fallback to include all non-ideal wishlist cars with available metadata, using an intermediate RemoveEntry struct. Adds test coverage for this scenario. --- src/output/mod.rs | 126 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 112 insertions(+), 14 deletions(-) diff --git a/src/output/mod.rs b/src/output/mod.rs index 7273a24..1fb915a 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -320,30 +320,61 @@ pub fn write_wishlist_files( )?; // --- remove.csv (wishlist cars not in the ideal set) --- - // Single pass: collect found entries and count dropped ones together. + // Use a struct that can represent both best-cars entries (with count) and + // cars only in the car_map (without count info). + struct RemoveEntry { + brand: String, + only_name: String, + year: u16, + rq: u8, + normalized_config: String, + track_count: usize, + } + + // Single pass: collect entries, using car_map as fallback for cars not in best data. let mut dropped_count = 0usize; - let to_remove: Vec<_> = wishlist + let to_remove: Vec = wishlist .iter() .filter(|guid| !ideal_guids.contains(*guid)) .filter_map(|guid| { + // First try: find the best entry for this car (if it's best on any track) if let Some(keys) = counts.guid_to_rid.get(guid) { // Pick the entry with the highest count (most impactful config) - keys.iter() + if let Some(key) = keys.iter() .max_by_key(|key| counts.by_rid.get(key).map(|(_, c)| *c).unwrap_or(0)) - .and_then(|key| counts.by_rid.get(key).cloned()) - .or_else(|| { - dropped_count += 1; - None - }) - } else { - dropped_count += 1; - None + { + if let Some((record, count)) = counts.by_rid.get(key) { + return Some(RemoveEntry { + brand: record.brand.clone(), + only_name: record.only_name.clone(), + year: record.year, + rq: record.rq, + normalized_config: record.normalized_config.clone(), + track_count: *count, + }); + } + } } + + // Fallback: use car_map for cars not in best data (not best on any track) + if let Some(car) = counts.car_map.get(guid) { + return Some(RemoveEntry { + brand: car.brand.clone(), + only_name: car.only_name.clone(), + year: car.year, + rq: car.rq, + normalized_config: String::new(), // No config info available + track_count: 0, // Not best on any track + }); + } + + dropped_count += 1; + None }) .collect(); if dropped_count > 0 { eprintln!( - "Warning: {dropped_count} wishlist car(s) not found in best cars data and omitted from remove.csv" + "Warning: {dropped_count} wishlist car(s) not found in cars database and omitted from remove.csv" ); } @@ -352,8 +383,24 @@ pub fn write_wishlist_files( "remove.csv", "RQ,Brand,Model,Year,FinalBuild,Count", &to_remove, - |a, b| compare_records(&a.0, &b.0), - format_counted_row, + |a, b| { + a.rq.cmp(&b.rq) + .then(a.normalized_config.cmp(&b.normalized_config)) + .then(a.brand.cmp(&b.brand)) + .then(a.only_name.cmp(&b.only_name)) + }, + |entry, file| { + writeln!( + file, + "{},{},{},{},{},{}", + entry.rq, + escape_csv_field(&entry.brand), + escape_csv_field(&entry.only_name), + entry.year, + entry.normalized_config, + entry.track_count + ) + }, )?; Ok(()) @@ -894,4 +941,55 @@ mod tests { assert_eq!(count, 0, "Car already at final build should not appear in improve.csv"); } + + // ======================================================================= + // remove.csv tests for cars not in best cars data + // ======================================================================= + + #[test] + fn test_wishlist_removes_cars_not_in_best_data() { + // This tests the fix: cars on the wishlist that are NOT best on any track + // should still be included in remove.csv (using car_map as fallback) + let ctx = AnalysisContext::new(); + + // Only Car_A is best on tracks + let rec_a = make_record("Car_A", 85, 100.0, "guid_a"); + for i in 0..15 { + ctx.best_cars.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_a.clone()]); + } + + // Car_B and Car_C are NOT best on any track (they only exist in the car list) + let car_list = vec![ + make_car("Car_A", 85, "guid_a"), // best on tracks + make_car("Car_B", 90, "guid_b"), // NOT best on any track + make_car("Car_C", 75, "guid_c"), // NOT best on any track + ]; + let counts = compute_car_counts(&ctx, car_list, empty_my_data()); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + + // Wishlist contains: guid_a (best car - should stay), guid_b (not best - should be removed), guid_c (not best - should be removed) + let wishlist: HashSet = vec![ + "guid_a".to_string(), + "guid_b".to_string(), + "guid_c".to_string(), + ] + .into_iter() + .collect(); + + write_wishlist_files(output_dir, &counts, &HashSet::new(), &wishlist, 10, 300).unwrap(); + + // Check remove.csv exists and contains both cars not in best data + let remove_path = dir.path().join("remove.csv"); + let content = fs::read_to_string(&remove_path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + + // Should have header + 2 cars (guid_b and guid_c) + assert_eq!(lines.len(), 3, "remove.csv should have header + 2 cars, got: {:?}", lines); + + // The cars should be in the file, sorted by RQ (75 first, then 90) + assert!(lines[1].starts_with("75,"), "First car should be RQ 75, got: {}", lines[1]); + assert!(lines[2].starts_with("90,"), "Second car should be RQ 90, got: {}", lines[2]); + } }