From fc9f637c6023fa2c7fea37cde7e0385c4d83833c Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Mon, 11 May 2026 20:16:09 +0200 Subject: [PATCH] Generalize config prefix stripping and refactor improve CSV output - Replace hardcoded v2c_/v1c_ prefix stripping with generic rsplit_once('_') approach that handles any prefix (v2b_, v3a_, etc.) while validating the suffix is exactly 3 ASCII digits - Refactor write_improve_csv to emit one row per normalized config per car instead of a single aggregated row, giving visibility into all viable upgrade paths - Add 4 new tests covering prefix stripping and improve CSV behavior --- src/output/mod.rs | 146 +++++++++++++++++++++++++++++++++++---------- src/parsing/mod.rs | 112 +++++++++++++++++++++++++++++++--- 2 files changed, 218 insertions(+), 40 deletions(-) diff --git a/src/output/mod.rs b/src/output/mod.rs index 7c368ae..7273a24 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -367,11 +367,12 @@ pub fn write_wishlist_files( /// (323/233/332), sorted by number of best tracks descending (most impactful upgrades first). /// /// Uses the **actual player config** from `my_cars.json` (engineMajor/weightMajor/chassisMajor) -/// rather than the timing data configs. This avoids false positives where a car already at a -/// final build appears in the list because timing data contains non-final configs. +/// to determine whether a car is finished. Only unfinished cars (config sum < 8) are included. /// -/// The `Target` column shows the normalized final build that the player should upgrade to. -/// The `Count` column shows how many tracks this car is best on (across all its timing configs). +/// For each unfinished car, one row per normalized config that the car is best on is emitted. +/// The `Target` column shows the ideal final build from the timing data (not derived from the +/// player's current config). The `Count` column shows how many tracks this car is best on +/// under that specific normalized config. /// /// Format: RQ,Brand,Model,Year,Target,Count pub fn write_improve_csv( @@ -393,12 +394,11 @@ pub fn write_improve_csv( let mut entries: Vec = Vec::new(); for (guid, actual_config) in &counts.owned_configs { - // Normalize the player's actual config - let target = normalize_to_final_build(actual_config); - - // If normalization didn't change the config, it's already a final build or maxed — skip - if target == *actual_config { - continue; + // Check if the player's actual config is already a final build (sum == 8) or maxed (sum > 8). + // Only unfinished cars need to be in improve.csv. + let normalized_actual = normalize_to_final_build(actual_config); + if normalized_actual == *actual_config { + continue; // Already a final build or maxed — skip } // Look up the car metadata @@ -409,32 +409,28 @@ pub fn write_improve_csv( continue; }; - // Count how many tracks this car is best on across ALL its normalized configs. - // This gives a "total impact" metric: upgrading this car benefits all tracks - // where any of its configs are best, not just those matching a single target. - let track_count: usize = counts - .guid_to_rid - .get(guid) - .map(|keys| { - keys.iter() - .filter_map(|key| counts.by_rid.get(key).map(|(_, c)| *c)) - .sum() - }) - .unwrap_or(0); - - entries.push(ImproveEntry { - brand: car.brand.clone(), - only_name: car.only_name.clone(), - year: car.year, - rq: car.rq, - target, - track_count, - }); + // Emit one row per normalized config that this car is best on. + // Each row shows the ideal target config and its respective track count, + // so the user can see all viable upgrade paths. + if let Some(keys) = counts.guid_to_rid.get(guid) { + for key in keys { + if let Some((record, track_count)) = counts.by_rid.get(key) { + entries.push(ImproveEntry { + brand: car.brand.clone(), + only_name: car.only_name.clone(), + year: car.year, + rq: car.rq, + target: record.normalized_config.clone(), + track_count: *track_count, + }); + } + } + } } let count = entries.len(); - // Sort by count descending (most tracks first), then by RQ/brand/model + // Sort by count descending (most tracks first), then by RQ/target/brand/model write_sorted_csv( output_dir, "improve.csv", @@ -812,4 +808,90 @@ mod tests { let wishlist = compute_ideal_wishlist(&counts, &no_owned, 10, 300); assert_eq!(wishlist.len(), 1, "No owned → wishlist includes the car"); } + + // ======================================================================= + // improve.csv tests + // ======================================================================= + + #[test] + fn test_improve_csv_multiple_configs() { + let ctx = AnalysisContext::new(); + + // Create records with two different normalized configs for the same car + let mut rec_332 = make_record("Car_A", 85, 90.0, "guid_a"); + rec_332.config = "111".to_string(); + rec_332.normalized_config = "332".to_string(); + + let mut rec_323 = make_record("Car_A", 85, 95.0, "guid_a"); + rec_323.config = "113".to_string(); + rec_323.normalized_config = "323".to_string(); + + // Car is best on 5 tracks with config 332 and 3 tracks with config 323 + for i in 0..5 { + ctx.best_cars + .insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_332.clone()]); + } + for i in 5..8 { + ctx.best_cars + .insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_323.clone()]); + } + + let car = make_car("Car_A", 85, "guid_a"); + + // Player owns the car with an unfinished config (sum = 3) + let mut owned_configs = HashMap::new(); + owned_configs.insert("guid_a".to_string(), "111".to_string()); + + let counts = compute_car_counts(&ctx, vec![car], owned_configs); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + + let count = write_improve_csv(output_dir, &counts).unwrap(); + + // Should produce 2 rows: one for 332 (count 5), one for 323 (count 3) + assert_eq!(count, 2, "Unfinished car should have one row per best config"); + + let path = dir.path().join("improve.csv"); + let content = fs::read_to_string(path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + + // Header + 2 data rows + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], "RQ,Brand,Model,Year,Target,Count"); + + // First row should be the config with more tracks (332, count 5) + assert!(lines[1].contains("332,5"), "First row should be 332 with count 5"); + // Second row should be 323 with count 3 + assert!(lines[2].contains("323,3"), "Second row should be 323 with count 3"); + } + + #[test] + fn test_improve_csv_skips_finished_car() { + let ctx = AnalysisContext::new(); + + let mut rec = make_record("Car_A", 85, 90.0, "guid_a"); + rec.config = "323".to_string(); + rec.normalized_config = "323".to_string(); + + for i in 0..5 { + ctx.best_cars + .insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec.clone()]); + } + + let car = make_car("Car_A", 85, "guid_a"); + + // Player owns the car already at a final build (323, sum = 8) + let mut owned_configs = HashMap::new(); + owned_configs.insert("guid_a".to_string(), "323".to_string()); + + let counts = compute_car_counts(&ctx, vec![car], owned_configs); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + + let count = write_improve_csv(output_dir, &counts).unwrap(); + + assert_eq!(count, 0, "Car already at final build should not appear in improve.csv"); + } } diff --git a/src/parsing/mod.rs b/src/parsing/mod.rs index 126d27c..1d91097 100644 --- a/src/parsing/mod.rs +++ b/src/parsing/mod.rs @@ -97,15 +97,17 @@ pub fn parse_car_times( // Process each configuration variant (111, 233, 323, etc.) if let Some(configurations) = json_data["data"].as_object() { for (raw_config, entry) in configurations { - // Strip version prefix (e.g., "v2c_" from "v2c_233") at read time. - // Downstream code only ever sees bare digit strings. - // Use explicit prefix matching to avoid mis-stripping keys that - // naturally contain underscores. + // Strip version prefix (e.g., "v2c_" from "v2c_233", "v2b_" from "v2b_111") + // at read time. Downstream code only ever sees bare digit strings. + // Generic approach: find the last '_' and take what follows it, + // but only if the remainder is exactly 3 ASCII digits. Otherwise + // keep the key unchanged (handles bare configs like "233" and + // unexpected formats gracefully). let config = raw_config - .strip_prefix("v2c_") - .or_else(|| raw_config.strip_prefix("v1c_")) - .unwrap_or(raw_config) - .to_string(); + .rsplit_once('_') + .filter(|(_, suffix)| suffix.len() == 3 && suffix.bytes().all(|b| b.is_ascii_digit())) + .map(|(_, suffix)| suffix.to_string()) + .unwrap_or_else(|| raw_config.clone()); // Process timing data for each track if let Some(times) = entry["times"].as_object() { for (track_id, details) in times { @@ -636,6 +638,100 @@ mod tests { assert_eq!(translate_track_type("99"), "99"); } + // ======================================================================= + // Prefix stripping tests + // ======================================================================= + + #[test] + fn test_parse_car_times_strips_v2b_prefix() { + let ctx = AnalysisContext::new(); + let car = Car { + rid: "TestCar_2020".to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq: 85, + prize: false, + guid: "guid_test".to_string(), + }; + + let dir = tempfile::tempdir().unwrap(); + let times_dir = dir.path().to_str().unwrap(); + let file_path = dir.path().join("TestCar_2020.json"); + + // Use "v2b_233" prefix — previously not stripped + let json_content = r#"{ + "data": { + "v2b_233": { + "times": { + "laguna_a00": { "t": 100.0, "u": "Player1" } + } + } + } + }"#; + fs::write(&file_path, json_content).unwrap(); + + let my_cars: HashSet = HashSet::new(); + parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap(); + + // Verify the config was stripped to "233" (not "v2b_233") + let records = ctx.best_cars.get(&("Laguna Seca".to_string(), 85, "Dry".to_string())).unwrap(); + assert_eq!(records[0].config, "233", "v2b_ prefix should be stripped"); + assert_eq!(records[0].normalized_config, "233", "233 is already a final build"); + } + + #[test] + fn test_parse_car_times_strips_various_prefixes() { + let ctx = AnalysisContext::new(); + let car = Car { + rid: "TestCar_2021".to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2021, + rq: 90, + prize: false, + guid: "guid_test2".to_string(), + }; + + let dir = tempfile::tempdir().unwrap(); + let times_dir = dir.path().to_str().unwrap(); + let file_path = dir.path().join("TestCar_2021.json"); + + // Mix of prefix styles: v2c_, v1c_, v3a_, and bare + let json_content = r#"{ + "data": { + "v2c_111": { + "times": { + "laguna_a00": { "t": 110.0, "u": "P1" } + } + }, + "v3a_233": { + "times": { + "laguna_a00": { "t": 95.0, "u": "P2" } + } + }, + "323": { + "times": { + "mnCity_a01": { "t": 88.0, "u": "P3" } + } + } + } + }"#; + fs::write(&file_path, json_content).unwrap(); + + let my_cars: HashSet = HashSet::new(); + parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap(); + + // Best on Laguna Seca should be the v3a_233 entry (95.0 beats 110.0) + let records = ctx.best_cars.get(&("Laguna Seca".to_string(), 90, "Dry".to_string())).unwrap(); + assert_eq!(records[0].config, "233", "v3a_ prefix should be stripped"); + assert_eq!(records[0].time, 95.0); + + // Monaco Streets from bare "323" + let records = ctx.best_cars.get(&("Monaco Streets".to_string(), 90, "Wet".to_string())).unwrap(); + assert_eq!(records[0].config, "323", "bare config should pass through"); + } + // ======================================================================= // Constants tests // =======================================================================