top_drives/src/parsing/mod.rs
Jan-Peter von Hunnius 293a8d7707 Add protect/storage lists, dynamic trash thresholds, tie resolution
Protect & storage lists:
- protect.csv: unlocked garage cars (state != 0, locked == false) worth
  locking more copies of. Needed copies = best configs surviving the
  RQ-band thresholds (prize cars and RQ >= 80 always count); already
  locked copies (copy_count) are subtracted, surplus drops off the list.
- storage.csv: Keep/Trash recommendation for storage cars (state == 0)
  using the same copy-count logic; surplus unlocked copies are fusion
  material.
- user_data.rs now parses card state (garage vs storage), garage slots
  (user.garageSlots/garageSlotsUsed), and unlocked card maps.

Dynamic trash thresholds:
- TrashThresholds derive RQ 20-29/30-39 as half/a quarter of a base
  value (--trash-base-count, default 4); RQ 40+ never covered.
- plan_trash_rules escalates the base automatically while projected free
  garage slots (capacity - used + unlocked non-best copies + new trash
  candidates, copies counted per card) stay below --garage-min-free
  (default 20), stopping when no further cars qualify. Effective
  thresholds and projection are printed.
- Improve-based low-value cars (best target count at or below the band
  threshold) land in trash.csv; collect_improve_entries is shared with
  improve.csv and computed once per run.

Same-car tie resolution:
- resolve_tied_configs collapses tie-equivalent configs of the same car
  per track (compute_car_counts, write_details_csv), preferring the
  player's owned final-build config. Fixes phantom rows like the
  Mercedes 280 SL 332 entry tied at 61.31 by its owned 323, inflated
  per-config track counts, and pointless second-copy recommendations.

Wishlist:
- Prize cars are excluded from the ideal wishlist and surface in
  remove.csv via the ideal-set difference.

CLI: new protect command; --trash-base-count/--garage-min-free on
trash/protect/all; details command reuses counts for tie resolution.
README updated for all new files, flags, and criteria.
2026-08-21 18:45:37 +02:00

853 lines
32 KiB
Rust

//! Core parsing logic for car timing data.
//!
//! Handles per-car JSON timing file parsing, best-car record updates,
//! and orchestrated concurrent parsing of all timing files.
pub mod car_list;
use std::collections::HashSet;
use std::fs;
use std::sync::Arc;
use dashmap::DashMap;
use futures::future::join_all;
use serde_json::Value;
use tokio::sync::Semaphore;
use tokio::task;
use top_drives::{
normalize_to_final_build, translate_track_name, translate_track_type, AnalysisContext,
AppError, BestCarRecord, Car, TripletKey,
};
use self::car_list::get_car_list;
// ---------------------------------------------------------------------------
// Constants (shared across parsing modules)
// ---------------------------------------------------------------------------
/// Maximum RQ level in the Top Drives database.
/// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors.
pub const MAX_RQ: u8 = 113;
/// Minimum valid track time in seconds. Times below this threshold are discarded.
pub const MIN_VALID_TIME: f64 = 1.2;
/// Tolerance for considering two times as equal (5ms).
pub const TIME_TOLERANCE: f64 = 0.005;
/// Multiplier constant for Test Bowl time inversion.
/// Test Bowl tracks use speed rather than time, so we invert: `FACTOR / time`.
pub const TEST_BOWL_FACTOR: f64 = 2000.0;
/// Maximum number of concurrent download/parse tasks.
///
/// IMPORTANT: Keep ≤ 50 to avoid exhausting tokio's default blocking thread pool (512 threads),
/// since each concurrent permit holder also occupies a `spawn_blocking` thread.
pub const CONCURRENCY_LIMIT: usize = 20;
// Compile-time guards: a bad edit to any constant above fails the build here
// instead of producing subtly wrong behavior at runtime.
const _: () = {
assert!(MAX_RQ > 0);
assert!(MIN_VALID_TIME > 0.0);
assert!(TIME_TOLERANCE > 0.0);
assert!(TEST_BOWL_FACTOR > 0.0);
assert!(CONCURRENCY_LIMIT > 0);
};
// ---------------------------------------------------------------------------
// Core parsing: per-car timing data
// ---------------------------------------------------------------------------
/// Parses timing data for a single car from its JSON file.
///
/// **Blocking** — performs synchronous file I/O and CPU-intensive JSON parsing.
/// Must be called from [`tokio::task::spawn_blocking`], never directly from an async context,
/// as it would block the tokio runtime thread.
///
/// This is the core function responsible for processing individual car performance data.
/// It reads the car's JSON file, extracts timing information for each track and condition,
/// filters out invalid or downvoted times, and updates the analysis context.
///
/// # Arguments
///
/// * `car` - The car data structure containing metadata about the car to process
/// * `my_cars` - Set of GUIDs the user owns (used to mark ownership)
/// * `best_cars` - Shared concurrent map of best car records (`DashMap`)
/// * `times_dir` - Directory containing per-car timing JSON files
pub fn parse_car_times(
car: Car,
my_cars: &HashSet<String>,
best_cars: &DashMap<TripletKey, Vec<BestCarRecord>>,
times_dir: &str,
) -> Result<(), AppError> {
// Construct file path for this car's timing data
let file_name = car.rid.to_owned() + ".json";
let local_file_path = format!("{times_dir}/{file_name}");
// Read file content
let file_content = match fs::read_to_string(&local_file_path) {
Ok(content) => content,
// File not found is expected for most cars (no timing data recorded) — silent skip.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => {
log::warn!("could not read {local_file_path}: {e}");
return Ok(());
}
};
// Parse JSON data
let json_data: Value = match serde_json::from_str(&file_content) {
Ok(v) => v,
Err(e) => {
return Err(AppError::Parse { file: local_file_path, reason: e.to_string() });
}
};
// Process each configuration variant (111, 233, 323, etc.)
if let Some(configurations) = json_data["data"].as_object() {
for (raw_config, config_data) in configurations {
// 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
.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) = config_data["times"].as_object() {
for (track_id, details) in times {
// Parse track ID to separate base track name from surface condition.
// Use rsplit_once to split on the LAST "_a" so track names that
// contain "_a" (e.g. "airplaneHangars") are handled correctly.
if let Some((cleaned_track_id, track_type)) = track_id.rsplit_once("_a") {
let Some(mut time) = details["t"].as_f64() else {
log::warn!(
"skipping {}/{}: missing or non-numeric time field",
car.rid,
track_id
);
continue;
};
let down_votes = details["down"].as_array();
let no_down_votes = match down_votes {
None => true,
Some(votes) => votes.is_empty(),
};
if track_id.starts_with("testBowl") {
if time > 0.0 {
time = TEST_BOWL_FACTOR / time;
} else {
continue; // skip invalid zero time
}
}
let owned = my_cars.contains(&car.guid);
if time > MIN_VALID_TIME && no_down_votes {
let track_name = translate_track_name(cleaned_track_id);
let surface = translate_track_type(track_type);
let normalized = normalize_to_final_build(&config);
update_best_car(
best_cars,
&track_name,
car.rq,
surface,
BestCarRecord {
car_rid: car.rid.clone(),
brand: car.brand.clone(),
only_name: car.only_name.clone(),
year: car.year,
rq: car.rq,
config: config.clone(),
normalized_config: normalized,
time,
owned,
prize: car.prize,
guid: car.guid.clone(),
},
);
}
}
}
}
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Core parsing: update best car records
// ---------------------------------------------------------------------------
/// Updates the best cars database with a new performance record.
///
/// Implements the core logic for determining the best performing cars across different
/// RQ ranges. For each RQ level from the car's base RQ up to MAX_RQ, this
/// function determines whether the new record represents an improvement or tie.
///
/// # Allocation Optimization
///
/// The record is cloned at most once (for the first insertion), and subsequent
/// RQ levels reuse the entry via `DashMap`'s `entry()` API, avoiding per-RQ
/// clone overhead for the common "insert new" case.
///
/// String keys (`track_id`, `surface`) are pre-allocated once and cloned per
/// iteration, avoiding repeated `to_string()` formatting overhead.
pub fn update_best_car(
best_cars: &DashMap<TripletKey, Vec<BestCarRecord>>,
track_id: &str,
car_rq: u8,
surface: &str,
new_record: BestCarRecord,
) {
let track_id_owned = track_id.to_string();
let surface_owned = surface.to_string();
for rq in car_rq..=MAX_RQ {
let is_last = rq == MAX_RQ;
let key = (track_id_owned.clone(), rq, surface_owned.clone());
match best_cars.entry(key) {
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
let existing_list = entry.get_mut();
let best_time = existing_list.first().map(|r| r.time).unwrap_or(f64::MAX);
if new_record.time < best_time {
if is_last {
*existing_list = vec![new_record];
return;
} else {
*existing_list = vec![new_record.clone()];
}
} else if (new_record.time - best_time).abs() < TIME_TOLERANCE
&& !existing_list.iter().any(|r| {
r.car_rid == new_record.car_rid
&& r.normalized_config == new_record.normalized_config
})
{
if is_last {
existing_list.push(new_record);
return;
} else {
existing_list.push(new_record.clone());
}
}
// else: record is discarded for this RQ — no clone needed
}
dashmap::mapref::entry::Entry::Vacant(entry) => {
if is_last {
entry.insert(vec![new_record]);
return;
} else {
entry.insert(vec![new_record.clone()]);
}
}
}
}
}
// ---------------------------------------------------------------------------
// Orchestrated parsing
// ---------------------------------------------------------------------------
/// Orchestrates concurrent parsing of all car timing files.
///
/// Spawns a separate async task for each car in the database. The semaphore is
/// acquired in the async context (avoiding `block_on` inside `spawn_blocking`,
/// which risks deadlocking the blocking thread pool), then the synchronous
/// parsing work runs inside `spawn_blocking` while the permit is held.
///
/// Takes `Arc<HashSet<String>>` to avoid cloning the entire owned-car set on
/// every call.
pub async fn parse_all_times(
my_cars: Arc<HashSet<String>>,
ctx: &AnalysisContext,
cars_db_path: &str,
times_dir: &str,
) -> Result<Vec<Car>, AppError> {
let car_list = get_car_list(cars_db_path)?;
let mut handles = vec![];
let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT));
let best_cars = Arc::clone(&ctx.best_cars);
for car in car_list.iter() {
let my_cars = Arc::clone(&my_cars);
let best_cars = Arc::clone(&best_cars);
let car = car.clone();
let sem = Arc::clone(&semaphore);
let times_dir = times_dir.to_string();
let handle = task::spawn(async move {
// Acquire the semaphore asynchronously — propagate error if closed.
let _permit = match sem.acquire().await {
Ok(p) => p,
Err(_) => return Err(AppError::Internal("Semaphore closed".into())),
};
// Run the synchronous parsing on a blocking thread while holding the permit.
task::spawn_blocking(move || parse_car_times(car, &my_cars, &best_cars, &times_dir))
.await
.unwrap_or_else(|e| {
Err(AppError::Internal(format!("Blocking task join error: {e}")))
})
});
handles.push(handle);
}
let results = join_all(handles).await;
let mut parse_errors = 0usize;
for result in results {
match result {
// Outer JoinError from tokio::spawn
Err(e) => {
log::warn!("parsing task failed: {e}");
parse_errors += 1;
}
// parse_car_times or semaphore returned an error
Ok(Err(e)) => {
log::warn!("{e}");
parse_errors += 1;
}
// parse_car_times succeeded
Ok(Ok(())) => {}
}
}
if parse_errors > 0 {
log::warn!("{parse_errors} car(s) had parse errors during analysis");
}
// If every car failed to parse, the data is unusable — propagate the error.
if parse_errors == car_list.len() && !car_list.is_empty() {
return Err(AppError::Parse {
file: times_dir.to_string(),
reason: format!(
"All {parse_errors} car(s) failed to parse — data directory may be corrupted"
),
});
}
// Missing timing files are silent (expected for most cars), so a wrong or
// empty --times-dir would otherwise produce empty CSVs without any hint.
if ctx.best_cars.is_empty() && !car_list.is_empty() {
log::warn!(
"no timing data found in {times_dir} for {} car(s) — all output CSVs will be empty",
car_list.len()
);
}
Ok(car_list)
}
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
/// Remote URL for the per-car timing API.
const CAR_TIMING_API_BASE: &str = "https://api.topdrivesrecords.com/car/";
/// Fetches timing data files for all cars using controlled concurrency.
///
/// Returns the first error encountered, or `Ok(())` if all downloads succeeded.
pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppError> {
let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT));
let mut handles = vec![];
for car in get_car_list(cars_db_path)? {
let permit = semaphore
.clone()
.acquire_owned()
.await
.map_err(|e| AppError::Network(format!("Semaphore closed: {e}")))?;
let times_dir = times_dir.to_string();
handles.push(tokio::spawn(async move {
let _permit = permit; // held until end of block
fetch_car(&car.rid, &times_dir).await
}));
}
let results = join_all(handles).await;
let mut first_error: Option<AppError> = None;
for result in results {
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
log::warn!("{e}");
if first_error.is_none() {
first_error = Some(e);
}
}
Err(e) => {
let msg = AppError::Network(format!("Task join error: {e}"));
log::warn!("{msg}");
if first_error.is_none() {
first_error = Some(msg);
}
}
}
}
first_error.map_or(Ok(()), Err)
}
/// Downloads timing data for a single car from the remote API.
async fn fetch_car(rid: &str, times_dir: &str) -> Result<(), AppError> {
let file_name = rid.to_owned() + ".json";
let url = format!("{CAR_TIMING_API_BASE}{rid}");
let local_file_path = format!("{times_dir}/{file_name}");
log::info!("Fetching {url}.");
top_drives::get_file(&url, &local_file_path).await
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use top_drives::test_utils::make_record;
// =======================================================================
// update_best_car tests
// =======================================================================
#[test]
fn test_update_best_car_inserts_new_record() {
let ctx = AnalysisContext::new();
let record = make_record("Car_A", 85, 100.0, "guid_a");
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record);
let key = ("laguna".to_string(), 85, "Dry".to_string());
assert!(ctx.best_cars.contains_key(&key));
let records = ctx.best_cars.get(&key).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].car_rid, "Car_A");
}
#[test]
fn test_update_best_car_replaces_slower_record() {
let ctx = AnalysisContext::new();
let slow = make_record("Car_A", 85, 110.0, "guid_a");
let fast = make_record("Car_B", 85, 95.0, "guid_b");
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", slow);
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", fast);
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].car_rid, "Car_B"); // faster car wins
}
#[test]
fn test_update_best_car_tie_within_tolerance() {
let ctx = AnalysisContext::new();
let record1 = make_record("Car_A", 85, 100.0, "guid_a");
let record2 = make_record("Car_B", 85, 100.003, "guid_b"); // within 0.005
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1);
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2);
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
assert_eq!(records.len(), 2, "Tied records should both be kept");
assert!(records.iter().any(|r| r.car_rid == "Car_A"));
assert!(records.iter().any(|r| r.car_rid == "Car_B"));
}
#[test]
fn test_update_best_car_outside_tolerance_replaces() {
let ctx = AnalysisContext::new();
let record1 = make_record("Car_A", 85, 100.0, "guid_a");
let record3 = make_record("Car_C", 85, 100.006, "guid_c"); // outside 0.005
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1);
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record3);
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
assert_eq!(records.len(), 1, "Record outside tolerance should not tie");
assert_eq!(records[0].car_rid, "Car_A"); // original kept (same best time)
}
#[test]
fn test_update_best_car_rq_range_propagation() {
let ctx = AnalysisContext::new();
let record = make_record("Car_A", 85, 100.0, "guid_a");
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record);
for rq in 85..=MAX_RQ {
let key = ("laguna".to_string(), rq, "Dry".to_string());
assert!(ctx.best_cars.contains_key(&key), "Car should appear at RQ level {rq}");
}
// Should NOT appear below its RQ
let key_below = ("laguna".to_string(), 84, "Dry".to_string());
assert!(!ctx.best_cars.contains_key(&key_below));
}
#[test]
fn test_update_best_car_duplicate_rid_not_added_in_tie() {
let ctx = AnalysisContext::new();
let record1 = make_record("Car_A", 85, 100.0, "guid_a");
let record2 = make_record("Car_A", 85, 100.001, "guid_a"); // same car, within tolerance
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1);
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2);
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
assert_eq!(records.len(), 1, "Same car_rid should not appear twice in tie list");
}
#[test]
fn test_update_best_car_same_car_different_config_slower_not_added() {
// A slower config of the same car is simply discarded (existing rule).
let ctx = AnalysisContext::new();
let mut record_323 = make_record("Car_A", 85, 100.0, "guid_a");
record_323.normalized_config = "323".to_string();
let mut record_332 = make_record("Car_A", 85, 100.4, "guid_a");
record_332.normalized_config = "332".to_string();
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_323);
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_332);
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].normalized_config, "323");
}
#[test]
fn test_update_best_car_same_car_different_config_faster_replaces() {
// A strictly faster config of the same car replaces the entry.
let ctx = AnalysisContext::new();
let mut record_323 = make_record("Car_A", 85, 100.0, "guid_a");
record_323.normalized_config = "323".to_string();
let mut record_332 = make_record("Car_A", 85, 99.0, "guid_a");
record_332.normalized_config = "332".to_string();
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_323);
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_332);
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].normalized_config, "332", "Faster config replaces the slower one");
}
// =======================================================================
// parse_car_times tests
// =======================================================================
#[test]
fn test_parse_car_times_valid_json() {
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(),
};
// Create a temporary directory and timing file
let dir = tempfile::tempdir().unwrap();
let times_dir = dir.path().to_str().unwrap();
let file_path = dir.path().join("TestCar_2020.json");
let json_content = r#"{
"data": {
"111": {
"times": {
"laguna_a00": { "t": 112.45, "u": "Player1" },
"mnCity_a01": { "t": 45.23, "u": "Player2" }
}
}
}
}"#;
fs::write(&file_path, json_content).unwrap();
let my_cars: HashSet<String> = HashSet::new();
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
// Should have entries for RQ 85..=113 for both tracks
assert!(ctx.best_cars.contains_key(&("Laguna Seca".to_string(), 85, "Dry".to_string())));
assert!(ctx.best_cars.contains_key(&("Monaco Streets".to_string(), 85, "Wet".to_string())));
}
#[test]
fn test_parse_car_times_skips_missing_or_non_numeric_time() {
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");
// "t" missing entirely, null, and a string — all must be skipped
// without panicking or inserting a record.
let json_content = r#"{
"data": {
"111": {
"times": {
"laguna_a00": { "u": "Player1" },
"mnCity_a01": { "t": null, "u": "Player2" },
"testBowl_a02": { "t": "112.45", "u": "Player3" },
"butte_a01": { "t": 50.0, "u": "Player4" }
}
}
}
}"#;
fs::write(&file_path, json_content).unwrap();
let my_cars: HashSet<String> = HashSet::new();
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
// Only the valid butte entry survives.
assert!(!ctx.best_cars.contains_key(&("Laguna Seca".to_string(), 85, "Dry".to_string())));
assert!(!ctx.best_cars.contains_key(&(
"Monaco Streets".to_string(),
85,
"Wet".to_string()
)));
assert!(
ctx.best_cars.contains_key(&("Butte".to_string(), 85, "Wet".to_string())),
"valid time entry should still be parsed"
);
}
#[test]
fn test_parse_car_times_filters_downvoted() {
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");
let json_content = r#"{
"data": {
"111": {
"times": {
"laguna_a00": { "t": 112.45, "u": "Player1", "down": ["Player3"] }
}
}
}
}"#;
fs::write(&file_path, json_content).unwrap();
let my_cars: HashSet<String> = HashSet::new();
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
// Downvoted time should be filtered out
assert!(
!ctx.best_cars.contains_key(&("Laguna Seca".to_string(), 85, "Dry".to_string())),
"Downvoted times should be excluded"
);
}
#[test]
fn test_parse_car_times_test_bowl_inversion() {
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");
let json_content = r#"{
"data": {
"111": {
"times": {
"testBowl_a00": { "t": 50.0, "u": "Player1" }
}
}
}
}"#;
fs::write(&file_path, json_content).unwrap();
let my_cars: HashSet<String> = HashSet::new();
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
// Test Bowl time should be inverted: 2000.0 / 50.0 = 40.0
let records = ctx.best_cars.get(&("Test Bowl".to_string(), 85, "Dry".to_string())).unwrap();
assert_eq!(records[0].time, 40.0);
}
#[test]
fn test_parse_car_times_missing_file() {
let ctx = AnalysisContext::new();
let car = Car {
rid: "NonExistent".to_string(),
brand: "TestBrand".to_string(),
only_name: "TestModel".to_string(),
year: 2020,
rq: 85,
prize: false,
guid: "guid_test".to_string(),
};
let my_cars: HashSet<String> = HashSet::new();
// Should not panic — just print a warning and return Ok(())
parse_car_times(car, &my_cars, &ctx.best_cars, "/nonexistent/times").unwrap();
assert_eq!(ctx.best_cars.len(), 0, "No entries for missing file");
}
// =======================================================================
// Track name / type tests (from lib)
// =======================================================================
#[test]
fn test_translate_track_name_known_tracks() {
assert_eq!(translate_track_name("laguna"), "Laguna Seca");
assert_eq!(translate_track_name("mnCity"), "Monaco Streets");
assert_eq!(translate_track_name("testBowl"), "Test Bowl");
assert_eq!(translate_track_name("unknown_track"), "unknown_track");
}
#[test]
fn test_translate_track_type_known_conditions() {
assert_eq!(translate_track_type("00"), "Dry");
assert_eq!(translate_track_type("01"), "Wet");
assert_eq!(translate_track_type("10"), "Dirt");
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<String> = 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<String> = 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");
}
}