refactor: extract lib.rs, remove club module, add bounded concurrency

- Extract shared types (Car, BestCarRecord, AnalysisContext) into src/lib.rs
- Replace global static BEST_CARS with injected AnalysisContext for testability
- Add semaphore-bounded concurrency (limit 20) in parse_all_times()
- Replace hardcoded track name match with track_names.json loaded via include_str!
- Replace magic number 115 with const MAX_RQ: u8 = 113
- Improve floating-point tie comparison with TIME_TOLERANCE (5ms) constant
- Remove src/club.rs binary (topdrives.club no longer maintained)
- Remove scraper dependency from Cargo.toml
- Remove tracks.txt (replaced by track_names.json)
- Remove empty/placeholder test bodies
- Improve error handling in get_file() for response body read
- Add load_my_cars_data() combining owned + wishlist extraction in one I/O
- Update CHANGELOG, README, and all docs to reflect changes
This commit is contained in:
Jan-Peter von Hunnius 2026-05-06 20:20:15 +02:00
parent 6a68469239
commit 89fa69e3c8
14 changed files with 874 additions and 2037 deletions

View file

@ -30,71 +30,23 @@
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::fs::File;
use std::io::BufReader;
use std::sync::{Arc, LazyLock, Mutex};
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task;
use futures::future::join_all;
use reqwest::get;
use serde_json::Value;
/// Core car information structure containing basic car metadata
/// Used throughout the application to represent car entities
#[derive(Debug, Clone)]
struct Car {
/// Unique car identifier used in the records database
rid: String,
/// Car manufacturer/brand name
brand: String,
/// Car model name (without brand)
only_name: String,
/// Car model year
year: u16,
/// Rarity level (10-113, affects performance brackets)
rq: u8,
/// Whether this car is only available as a prize car
prize: bool,
/// Game-specific unique identifier for the car
guid: String,
}
use top_drives::{
AnalysisContext, BestCarRecord, Car, TripletKey,
compare_cars, get_file, load_my_cars_data, translate_track_name, translate_track_type,
};
/// Represents the best performing car record for a specific track, RQ, and condition
/// Contains all necessary information to compare car performance
#[derive(Debug, Clone)]
struct BestCarRecord {
/// Car identifier from the records database
car_rid: String,
/// Car manufacturer/brand name
brand: String,
/// Car model name (without brand)
only_name: String,
/// Car model year
year: u16,
/// Rarity level of this specific car configuration
rq: u8,
/// Tune configuration string (e.g., "111", "233", "323")
config: String,
/// Track time in seconds (lower is better)
time: f64,
/// Whether the current user owns this car
owned: bool,
/// Game-specific unique identifier for the car
guid: String,
}
/// Type alias for the composite key used to identify unique track conditions
/// Consists of: (Track ID, RQ Level, Surface Condition)
type TripletKey = (String, u8, String);
/// Global cache storing the best car records for each track, RQ, and surface combination
/// Protected by a Mutex for thread-safe access during concurrent parsing operations
/// Uses std::sync::LazyLock for one-time initialization
static BEST_CARS: LazyLock<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Maximum RQ level in the Top Drives database.
/// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors.
const MAX_RQ: u8 = 113;
/// Main entry point for the Top Drives Records Analyzer
///
@ -140,37 +92,42 @@ async fn main() {
// Standard best cars analysis
// Shows cars you own that perform well across tracks
let my_data = load_my_cars_data();
let _car_list = parse_all_times(&my_data.owned).await;
print_best_cars(false, &my_data.wishlist); // false = include owned cars
let ctx = AnalysisContext::new();
let _car_list = parse_all_times(&my_data.owned, &ctx).await;
print_best_cars(&ctx, false, &my_data.wishlist); // false = include owned cars
}
"missing" => {
// Missing cars analysis
// Shows high-performing cars you don't own
// Cars not yet on the wishlist are prefixed with "+"
let my_data = load_my_cars_data();
let _car_list = parse_all_times(&my_data.owned).await;
print_best_cars(true, &my_data.wishlist); // true = filter out owned cars
let ctx = AnalysisContext::new();
let _car_list = parse_all_times(&my_data.owned, &ctx).await;
print_best_cars(&ctx, true, &my_data.wishlist); // true = filter out owned cars
}
"wishlist" => {
// Wishlist review
// Shows cars on the wishlist that are no longer among the best
let my_data = load_my_cars_data();
let car_list = parse_all_times(&my_data.owned).await;
print_wishlist_stale(&my_data.wishlist, &car_list);
let ctx = AnalysisContext::new();
let car_list = parse_all_times(&my_data.owned, &ctx).await;
print_wishlist_stale(&ctx, &my_data.wishlist, &car_list);
}
"trash" => {
// Collection optimization
// Identifies cars that can be safely removed
let my_data = load_my_cars_data();
let car_list = parse_all_times(&my_data.owned).await;
print_trash(&car_list, &my_data.owned);
let ctx = AnalysisContext::new();
let car_list = parse_all_times(&my_data.owned, &ctx).await;
print_trash(&ctx, &car_list, &my_data.owned);
}
"details" => {
// Comprehensive data export
// Outputs all timing data for external analysis
let my_data = load_my_cars_data();
parse_all_times(&my_data.owned).await;
print_best_times();
let ctx = AnalysisContext::new();
parse_all_times(&my_data.owned, &ctx).await;
print_best_times(&ctx);
}
"track" => {
// Track-specific analysis
@ -180,11 +137,12 @@ async fn main() {
} else {
// Safe to unwrap here due to above check
let my_data = load_my_cars_data();
parse_all_times(&my_data.owned).await;
let ctx = AnalysisContext::new();
parse_all_times(&my_data.owned, &ctx).await;
let track = args.get(2).unwrap();
let condition = args.get(3).unwrap();
let rq = args.get(4).unwrap();
print_best_car(track, condition, rq);
print_best_car(&ctx, track, condition, rq);
}
}
&_ => {
@ -202,7 +160,10 @@ async fn main() {
///
/// # Arguments
///
/// * `ctx` - Analysis context containing the best cars data
/// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file
/// * `owned` - Set of GUIDs the user owns
///
/// Only considers cars that are not among the best performers and are below RQ 80 (to avoid
/// removing potentially useful higher-end cars).
///
@ -216,11 +177,11 @@ async fn main() {
/// # Note
/// Prize cars are excluded from trash recommendations as they may have collection value
/// beyond their performance characteristics.
fn print_trash(car_list: &[Car], owned: &HashSet<String>) {
fn print_trash(ctx: &AnalysisContext, car_list: &[Car], owned: &HashSet<String>) {
// Extract GUIDs of all best performing cars across all tracks and conditions
// Lock is scoped to avoid holding it during file I/O below
let best_cars: HashSet<_> = {
let guard = BEST_CARS.lock().unwrap();
let guard = ctx.best_cars.lock().unwrap();
guard
.values()
.flat_map(|records| records.iter().map(|record| record.guid.clone()))
@ -248,98 +209,6 @@ fn print_trash(car_list: &[Car], owned: &HashSet<String>) {
}
}
/// Parsed user data from `my_cars.json`
///
/// Holds both the owned car GUIDs and wishlist GUIDs extracted from a single
/// file read, avoiding duplicate I/O.
struct MyCarsData {
/// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`)
owned: HashSet<String>,
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
wishlist: HashSet<String>,
}
/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data
///
/// Opens the game export file a single time and extracts:
/// - **Owned cars** from `playerDeck[].cardId` (filtered by `locked == true`)
/// - **Wishlist** from `wishlists[0].cards`
///
/// # File Source
///
/// * **File**: `my_cars.json` (exported from Top Drives game)
///
/// # Error Handling
///
/// Returns an empty `MyCarsData` (both sets empty) with a warning if the file
/// is missing or cannot be parsed, matching the graceful degradation pattern
/// used throughout the codebase.
fn load_my_cars_data() -> MyCarsData {
let file = match File::open("my_cars.json") {
Ok(f) => f,
Err(e) => {
eprintln!("Warning: could not open my_cars.json: {e}");
return MyCarsData {
owned: HashSet::new(),
wishlist: HashSet::new(),
};
}
};
let reader = BufReader::new(file);
let json: Value = match serde_json::from_reader(reader) {
Ok(v) => v,
Err(e) => {
eprintln!("Warning: could not parse my_cars.json: {e}");
return MyCarsData {
owned: HashSet::new(),
wishlist: HashSet::new(),
};
}
};
// Extract owned car GUIDs from playerDeck
let owned: HashSet<String> = json
.get("playerDeck")
.and_then(|d| d.as_array())
.map(|deck| {
deck.iter()
.filter_map(|card| {
if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) {
card.get("cardId").and_then(|v| v.as_str()).map(|s| s.to_string())
} else {
None
}
})
.collect()
})
.unwrap_or_default();
// Extract wishlist GUIDs from wishlists[0].cards
let wishlist: HashSet<String> = json
.get("wishlists")
.and_then(|w| w.get(0))
.and_then(|w| w.get("cards"))
.and_then(|c| c.as_array())
.map(|cards| {
cards
.iter()
.filter_map(|c| c.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
MyCarsData { owned, wishlist }
}
/// Comparison function for sorting cars by RQ, then brand, then model name
///
/// Used as a shared comparator to ensure consistent ordering across output functions.
fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering {
a.rq.cmp(&b.rq)
.then(a.brand.cmp(&b.brand))
.then(a.only_name.cmp(&b.only_name))
}
/// Prints cars on the wishlist that are no longer among the best performers
///
/// Compares the user's wishlist (from `my_cars.json`) against the current best-car
@ -358,13 +227,14 @@ fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering {
///
/// # Arguments
///
/// * `ctx` - Analysis context containing the best cars data
/// * `wishlist` - Set of car GUIDs loaded from the wishlists section of `my_cars.json`
/// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file
fn print_wishlist_stale(wishlist: &HashSet<String>, car_list: &[Car]) {
fn print_wishlist_stale(ctx: &AnalysisContext, wishlist: &HashSet<String>, car_list: &[Car]) {
// Collect GUIDs of all cars that are currently best for at least one combination
// Lock is scoped to avoid holding it during file I/O below
let current_best_guids: HashSet<String> = {
let best_cars = BEST_CARS.lock().unwrap();
let best_cars = ctx.best_cars.lock().unwrap();
best_cars
.values()
.flat_map(|records| records.iter().map(|r| r.guid.clone()))
@ -405,10 +275,10 @@ fn print_wishlist_stale(wishlist: &HashSet<String>, car_list: &[Car]) {
/// # Note
/// This generates a large amount of output data and is primarily used for
/// external analysis or when detailed performance data is required.
fn print_best_times() {
fn print_best_times(ctx: &AnalysisContext) {
// Scope the lock to only the data extraction phase
let sorted_data: Vec<((String, u8, String), Vec<BestCarRecord>)> = {
let best_cars = BEST_CARS.lock().unwrap();
let sorted_data: Vec<(TripletKey, Vec<BestCarRecord>)> = {
let best_cars = ctx.best_cars.lock().unwrap();
// Collect keys and sort them by RQ and Track ID for consistent output
let mut sorted_keys: Vec<_> = best_cars.keys().collect();
@ -445,7 +315,9 @@ fn print_best_times() {
///
/// # Arguments
///
/// * `ctx` - Analysis context containing the best cars data
/// * `filter_out_owned` - If true, excludes cars already owned by the user from the output
/// * `wishlist` - Set of car GUIDs on the user's wishlist
///
/// # Output Format
/// ```csv
@ -458,10 +330,10 @@ fn print_best_times() {
/// # Note
/// The count represents how many times this car achieved the best time across different
/// track/RQ/condition combinations, indicating overall performance quality.
fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet<String>) {
fn print_best_cars(ctx: &AnalysisContext, filter_out_owned: bool, wishlist: &HashSet<String>) {
// Scope the lock to only the data extraction phase
let car_count: HashMap<String, (BestCarRecord, usize)> = {
let best_cars = BEST_CARS.lock().unwrap();
let best_cars = ctx.best_cars.lock().unwrap();
let mut counts: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
// Count occurrences of each car across all tracks and conditions
@ -507,6 +379,7 @@ fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet<String>) {
///
/// # Arguments
///
/// * `ctx` - Analysis context containing the best cars data
/// * `track` - The track identifier (e.g., "laguna", "monaco")
/// * `condition` - Surface condition (e.g., "Dry", "Wet", "Dirt")
/// * `rq` - RQ level limit (cars with this RQ or lower will be considered)
@ -519,11 +392,11 @@ fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet<String>) {
/// # Note
/// If no car is found for the specified combination, it may indicate that no data
/// is available for that specific track/condition/RQ combination.
fn print_best_car(track: &String, condition: &String, rq: &String) {
let best_cars = BEST_CARS.lock().unwrap();
fn print_best_car(ctx: &AnalysisContext, track: &String, condition: &String, rq: &String) {
let best_cars = ctx.best_cars.lock().unwrap();
let key = (
track.clone(),
rq.parse::<u8>().unwrap_or(115), // Default to max RQ if parsing fails
rq.parse::<u8>().unwrap_or(MAX_RQ), // Default to max RQ if parsing fails
condition.clone(),
);
@ -552,11 +425,13 @@ fn print_best_car(track: &String, condition: &String, rq: &String) {
///
/// 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 global best cars database.
/// 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)
/// * `ctx` - Analysis context containing the shared best cars map
///
/// # Data Processing
///
@ -565,7 +440,7 @@ fn print_best_car(track: &String, condition: &String, rq: &String) {
/// 3. **Track Processing**: Extracts timing data for each track and surface condition
/// 4. **Validation**: Filters out invalid times and downvoted entries
/// 5. **Time Conversion**: Handles special track calculations (e.g., Test Bowl)
/// 6. **Database Update**: Updates the global best cars cache with new records
/// 6. **Database Update**: Updates the analysis context with new records
///
/// # Special Cases
///
@ -577,7 +452,7 @@ fn print_best_car(track: &String, condition: &String, rq: &String) {
///
/// This function is designed to be called concurrently for multiple cars to improve
/// overall processing speed when analyzing large car databases.
async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
async fn parse_car_times(car: Car, my_cars: &HashSet<String>, ctx: &AnalysisContext) {
// Construct file path for this car's timing data
let file_name = car.rid.to_owned() + ".json";
let local_file_path = "times/".to_owned() + file_name.as_str();
@ -634,7 +509,8 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
// Apply quality filters before processing
if time > 1.2 && no_down_votes {
update_best_car(
translate_track_name(cleaned_track_id),
&ctx.best_cars,
&translate_track_name(cleaned_track_id),
car.rq,
translate_track_type(track_type),
BestCarRecord {
@ -656,14 +532,15 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
}
}
}
/// Updates the global best cars database with a new performance record
/// 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 the maximum (115), this
/// 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.
///
/// # Arguments
///
/// * `best_cars` - Shared mutable reference to the best cars map
/// * `track_id` - The track identifier
/// * `car_rq` - The base RQ level of the car
/// * `surface` - The track surface condition (Dry, Wet, etc.)
@ -671,7 +548,7 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
///
/// # Algorithm
///
/// For each RQ level from car's RQ to 115:
/// For each RQ level from car's RQ to MAX_RQ:
/// 1. **Better Time**: If new record is faster than current best, replace the list
/// 2. **Tie Time**: If new record matches current best time, add to list (if not duplicate)
/// 3. **Worse Time**: Ignore the new record
@ -681,11 +558,20 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
/// The RQ range iteration allows cars to compete at higher RQ levels than their base
/// rating, simulating how cars might perform when upgraded or in different matchups.
/// This provides more comprehensive analysis across the entire performance spectrum.
fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCarRecord) {
let mut best_cars = BEST_CARS.lock().unwrap();
fn update_best_car(
best_cars: &Arc<std::sync::Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
track_id: &str,
car_rq: u8,
surface: &str,
new_record: BestCarRecord,
) {
/// Tolerance for considering two times as equal (5ms)
const TIME_TOLERANCE: f64 = 0.005;
// Check performance across RQ range from car's base RQ up to maximum (115)
for rq in car_rq..=115 {
let mut best_cars = best_cars.lock().unwrap();
// Check performance across RQ range from car's base RQ up to MAX_RQ
for rq in car_rq..=MAX_RQ {
let key = (track_id.to_string(), rq, surface.to_string());
match best_cars.get_mut(&key) {
@ -696,8 +582,8 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
// New record is faster than current best
// Replace entire list - only keep the fastest car
*existing_list = vec![new_record.clone()];
} else if (new_record.time - best_time).abs() < f64::EPSILON {
// Times are essentially equal (within floating point precision)
} else if (new_record.time - best_time).abs() < TIME_TOLERANCE {
// Times are essentially equal (within tolerance)
// This is a tie - add the car only if not already present
// Prevents duplicate entries for the same car
if !existing_list
@ -726,8 +612,9 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
///
/// # Performance
///
/// Uses Tokio's spawn system to create one task per car, enabling concurrent
/// file I/O and JSON parsing operations. The function waits for all tasks to
/// Uses Tokio's spawn system with a semaphore to create one task per car,
/// enabling concurrent file I/O and JSON parsing operations while limiting
/// concurrency to 20 parallel tasks. The function waits for all tasks to
/// complete before returning.
///
/// # Note
@ -738,18 +625,24 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
/// # Returns
///
/// Returns the full car list so callers can reuse it without re-reading the file.
async fn parse_all_times(my_cars: &HashSet<String>) -> Vec<Car> {
async fn parse_all_times(my_cars: &HashSet<String>, ctx: &AnalysisContext) -> Vec<Car> {
// Use Arc<HashSet> for efficient O(1) lookups across concurrent tasks
let my_cars = Arc::new(my_cars.clone());
let car_list = get_car_list();
let mut handles = vec![];
// Limit concurrent parsing to 20 to avoid excessive resource usage
let semaphore = Arc::new(Semaphore::new(20));
// Create one async task per car for concurrent processing
for car in car_list.iter() {
let my_cars = Arc::clone(&my_cars);
let best_cars = Arc::clone(&ctx.best_cars);
let car = car.clone();
let sem = Arc::clone(&semaphore);
let handle = task::spawn(async move {
parse_car_times(car, &my_cars).await;
let _permit = sem.acquire().await.unwrap();
parse_car_times(car, &my_cars, &AnalysisContext { best_cars }).await;
});
handles.push(handle);
}
@ -908,136 +801,10 @@ async fn get_cars_file() {
println!("Fetching cars file...");
get_file("https://github.com/TiagoXavi/topdrives-records/raw/refs/heads/main/src/database/cars_final.json", "cars_repo.json").await;
}
/// Downloads a file from a URL and saves it locally
///
/// Generic utility function for fetching files from remote URLs. Used by
/// both `get_cars_file()` and `get_tracks_file()` to download the latest
/// database files from the repository.
///
/// # Arguments
///
/// * `file_url` - The remote URL to download from
/// * `local_file_path` - Local file path where the downloaded content should be saved
///
/// # Behavior
///
/// 1. Removes any existing file at the local path
/// 2. Downloads the file from the remote URL
/// 3. Saves the content to the local file path
/// 4. Reports any errors that occur during the process
///
/// # Error Handling
///
/// Prints error messages for network failures but does not panic, allowing
/// the application to continue with cached data if available.
async fn get_file(file_url: &str, local_file_path: &str) {
// Remove existing file to ensure fresh download
let _ = fs::remove_file(local_file_path);
match get(file_url).await {
Ok(response) => {
// Download successful, save to local file
fs::write(local_file_path, response.text().await.unwrap())
.expect("Unable to write file!");
}
Err(error) => {
// Download failed, print error but don't panic
eprintln!("Failed to fetch the file. Status: {}", error.to_string());
}
}
}
/// Translates track condition codes to human-readable surface condition names
///
/// Converts internal track condition codes (like "00", "01", "10") to descriptive
/// names that represent the actual racing surface conditions in Top Drives.
///
/// # Arguments
///
/// * `track_code` - The internal condition code from the timing data
///
/// # Returns
///
/// Returns a human-readable string describing the track surface condition:
/// - "00" → "Dry" (standard asphalt)
/// - "01" → "Wet" (rain-affected)
/// - "10" → "Dirt" (off-road conditions)
/// - And many more specialized conditions
///
/// # Coverage
///
/// Supports all known track condition codes used in the Top Drives game,
/// including special combinations like "Asphalt Dirt Wet" and "Snow Dirt Wet".
fn translate_track_type(track_code: &str) -> &str {
match track_code {
"00" => "Dry",
"01" => "Wet",
"10" => "Dirt",
"11" => "Dirt Wet",
"20" => "Gravel",
"30" => "Ice",
"40" => "Asphalt Dirt",
"41" => "Asphalt Dirt Wet",
"50" => "Sand",
"60" => "Snow",
"70" => "Grass",
"71" => "Grass Wet",
"b0" => "Asphalt Gravel",
"c0" => "Asphalt Sand",
"c1" => "Asphalt Sand Wet",
"d0" => "Asphalt Snow",
"e0" => "Sand Dirt",
"f0" => "Asphalt Grass Dirt",
"g0" => "Ice Snow",
"h1" => "Snow Dirt Wet",
_ => track_code,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
/// Test data for mocking car database
fn create_test_car_data() -> Vec<Value> {
vec![
serde_json::json!({
"rid": "Test_Car_1",
"brand": "TestBrand",
"onlyName": "TestCar1",
"year": 2020,
"rq": 85,
"prize": false,
"guid": "test_guid_1"
}),
serde_json::json!({
"rid": "Test_Car_2",
"brand": "TestBrand",
"onlyName": "TestCar2",
"year": 2019,
"rq": 90,
"prize": true,
"guid": "test_guid_2"
}),
]
}
/// Test data for mocking user collection
fn create_test_user_data() -> Value {
serde_json::json!({
"playerDeck": [
{
"cardId": "test_guid_1",
"locked": true
},
{
"cardId": "test_guid_2",
"locked": false
}
]
})
}
#[test]
fn test_split_once() {
@ -1087,42 +854,9 @@ mod tests {
assert_eq!(translate_track_type("99"), "99");
}
#[test]
fn test_get_car_list() {
let temp_dir = tempdir().unwrap();
let cars_file = temp_dir.path().join("test_cars.json");
// Create test data file
let test_cars = create_test_car_data();
fs::write(&cars_file, serde_json::to_string(&test_cars).unwrap()).unwrap();
// Mock the file read by temporarily replacing the file
// Note: In a real scenario, we'd use a more sophisticated mocking approach
// For now, we'll test the logic indirectly through other functions
}
#[test]
fn test_get_my_car_list() {
let temp_dir = tempdir().unwrap();
let user_file = temp_dir.path().join("test_my_cars.json");
// Create test user data file
let test_user_data = create_test_user_data();
fs::write(&user_file, serde_json::to_string(&test_user_data).unwrap()).unwrap();
// Note: Testing this function requires file system mocking
// In a production environment, we'd use dependency injection or trait objects
}
#[test]
fn test_update_best_car_logic() {
// Note: We can't easily test the global BEST_CARS mutex in unit tests
// due to potential poisoning and concurrent access issues.
// In a real testing environment, we'd use dependency injection or trait objects.
// For now, we'll test the logic indirectly through other means
// This test serves as documentation of expected behavior
// Verify test data is constructed correctly
let test_record = BestCarRecord {
car_rid: "Test_Car".to_string(),
brand: "TestBrand".to_string(),
@ -1135,7 +869,6 @@ mod tests {
guid: "test_guid".to_string(),
};
// Verify test data is constructed correctly
assert_eq!(test_record.time, 100.0);
assert_eq!(test_record.rq, 85);
assert_eq!(test_record.config, "111");
@ -1148,8 +881,7 @@ mod tests {
#[test]
fn test_rq_range_analysis() {
// Reset global state
*BEST_CARS.lock().unwrap() = HashMap::new();
let ctx = AnalysisContext::new();
let test_record = BestCarRecord {
car_rid: "Test_Car".to_string(),
@ -1163,13 +895,13 @@ mod tests {
guid: "test_guid".to_string(),
};
// Test that car appears in RQ range from its base RQ to 115
update_best_car("laguna", 85, "Dry", test_record);
// Test that car appears in RQ range from its base RQ to MAX_RQ
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", test_record);
let best_cars = BEST_CARS.lock().unwrap();
let best_cars = ctx.best_cars.lock().unwrap();
// Should appear at RQ 85, 90, 95, ..., 115
for rq in 85..=115 {
// Should appear at RQ 85, 90, 95, ..., MAX_RQ
for rq in 85..=MAX_RQ {
let key = ("laguna".to_string(), rq, "Dry".to_string());
assert!(
best_cars.contains_key(&key),
@ -1186,9 +918,6 @@ mod tests {
// - Valid times should be processed normally
// - This logic is tested indirectly through the parsing functions
// Note: Direct testing of global state is complex due to mutex locking
// In a production environment, we'd use mocks or dependency injection
let valid_time = 112.45; // Valid time
let invalid_time = 1.0; // Invalid time (too low)
@ -1236,174 +965,62 @@ mod tests {
assert!(!name.is_empty(), "Condition name should not be empty");
}
}
}
/// Translates internal track identifiers to human-readable track names
///
/// Converts compact internal track codes (like "laguna", "monaco", "testBowl")
/// to their full, descriptive names that appear in the Top Drives game interface.
///
/// # Arguments
///
/// * `track_code` - The internal track identifier from the timing data
///
/// # Returns
///
/// Returns the full track name as displayed in the game:
/// - "laguna" → "Laguna Seca"
/// - "monaco" → "Monaco Streets"
/// - "testBowl" → "Test Bowl"
/// - And hundreds more track mappings
///
/// # Coverage
///
/// Contains mappings for all known tracks in the Top Drives game, including
/// special tracks like drag strips, rally stages, and circuit races.
///
/// # Note
///
/// If an unknown track code is provided, the original code is returned
/// unchanged, allowing the system to gracefully handle new tracks.
fn translate_track_name(track_code: &str) -> &str {
match track_code {
"drag100b" => "0-100-0mph",
"drag100" => "0-100mph",
"drag120" => "0-120mph",
"drag124" => "0-124mph",
"drag150b" => "0-150-0mph",
"drag150" => "0-150mph",
"drag170" => "0-170mph",
"drag200" => "0-200mph",
"drag60b" => "0-60-0mph",
"drag60" => "0-60mph",
"drag62" => "0-62mph",
"mile1" => "1 Mile Drag",
"mile1r" => "1 Mile Drag R",
"mile2" => "1/2 Mile Drag",
"mile4" => "1/4 Mile Drag",
"mile4r" => "1/4 Mile Drag R",
"drag100150" => "100-150mph",
"drag30130" => "30-130mph",
"drag50150" => "50-150mph",
"drag75125" => "75-125mph",
"airplaneHangars" => "Airplane Hangars",
"airplaneSlalom" => "Airplane Slalom",
"smallRoad" => "Back Road",
"butte" => "Butte",
"canyonButte" => "Canyon Butte",
"lookout" => "Lookout",
"canyonLookout" => "Canyon Lookout",
"carPark" => "Car Park",
"townSlalom" => "City Slalom",
"townSprint" => "City Sprint",
"csMed" => "City Streets Medium",
"csSmall" => "City Streets Small",
"townTour" => "City Tour",
"cross" => "Cross-Country",
"dealsGap" => "Deals Gap",
"dealsGap1" => "Deals Gap Relay 1",
"dealsGap2" => "Deals Gap Relay 2",
"dealsGap3" => "Deals Gap Relay 3",
"dealsGap4" => "Deals Gap Relay 4",
"dealsGap5" => "Deals Gap Relay 5",
"dealsGapBack" => "Deals Gap Backroads",
"desertBig" => "Desert Rally Big",
"desertHill" => "Desert Rally Hill",
"desertRallyDirt" => "Desert Rally Dirt",
"desertSmall" => "Desert Rally Small",
"dockAltCirc" => "Dockyard Alternative Circuit",
"dockCirc" => "Dockyard Circuit",
"dockCity" => "Dockyard City Streets",
"dockGforceDrag" => "Dockyard G-Force Drag",
"dockKart" => "Dockyard Karting",
"dockUdrag" => "Dockyard U-Drag",
"dtRoad" => "Dirt Road",
"canyonDtRoad" => "Canyon Dirt Road",
"fast" => "Fast Circuit",
"fastr" => "Fast Circuit R",
"fieldSlalom" => "Field Slalom",
"figureEight" => "Figure Of Eight",
"forestRiver" => "Forest River Crossing",
"forest" => "Forest Road",
"forestSlalom" => "Forest Slalom",
"frozenLake" => "Frozen Lake",
"frozenRivSla" => "Frozen River Slalom",
"gForce" => "G-Force Test",
"gForcer" => "G-Force Test R",
"hairpin" => "Hairpin Road",
"hClimb" => "Hill Climb",
"hClimbr" => "Hill Climb R",
"indoorKart" => "Indoor Karting",
"kart" => "Karting Circuit",
"draglshape" => "L-Shape Drag",
"laguna" => "Laguna Seca",
"lumberForest" => "Lumber Mill Forest Rally",
"lumberRiver" => "Lumber Mill River Rally",
"lumberTwisty" => "Lumber Mill Twisty Circuit",
"miCircuit" => "Miami Circuit",
"miStreets1" => "Miami Streets 1",
"miStreets2" => "Miami Streets 2",
"miUdrag" => "Miami U-Drag",
"miCause" => "Miami Causeway",
"miCauseDrag" => "Miami Causeway Drag",
"miBridge" => "Miami Bridge",
"miFigure" => "Miami Figure Of Eight",
"miSlalom" => "Miami Slalom",
"miGforce" => "Miami G-Force",
"miBeachDrag" => "Miami Beach Drag",
"mnGforce" => "Monaco G-Force Test",
"mnHairpin" => "Monaco Hairpin",
"mnCityNarrow" => "Monaco Narrow Streets",
"mnCity" => "Monaco Streets",
"mnCityLong" => "Monaco Streets Long",
"moto" => "Motocross Track",
"mtHairpin" => "Mountain Hairpin",
"mtHill" => "Mountain Hill Climb",
"mtIncline" => "Mountain Incline Road",
"mtSlalom" => "Mountain Slalom",
"mtTour" => "Mountain Tour",
"mtTwisty" => "Mountain Twisty Road",
"nwCircuit" => "New York Circuit",
"nwDrag" => "New York Drag",
"nwGforce" => "New York G-Force",
"nwLoop" => "New York Times Square Loop",
"nwCathedral" => "New York Cathedral",
"nwTour" => "New York Central Park Tour",
"nwSlalom" => "New York Train Slalom",
"northloop" => "North Loop",
"northloop1" => "North Loop Relay 1",
"northloop2" => "North Loop Relay 2",
"northloop3" => "North Loop Relay 3",
"northloop4" => "North Loop Relay 4",
"northloop5" => "North Loop Relay 5",
"oceanSlalom" => "Ocean Beach Slalom",
"oceanCity" => "Ocean City Streets",
"oceanHighway" => "Ocean Highway",
"oceanLongDrag" => "Ocean Long Drag",
"oceanParking" => "Ocean Parking Lot",
"oceanShortDrag" => "Ocean Short Drag",
"rallyMed" => "Rallycross Medium Circuit",
"rallySmall" => "Rallycross Small Circuit",
"riverSprint" => "River Sprint",
"runwayDrag" => "Runway Drag",
"runwayDragSlalom" => "Runway Drag & Slalom",
"runwayLoop" => "Runway Loop",
"serviceRoads" => "Service Roads",
"slalom" => "Slalom Test",
"slalomr" => "Slalom Test R",
"speedbump12km" => "Speedbump 1 km",
"speedbump1km" => "Speedbump 1/2 km",
"testBowl" => "Test Bowl",
"testBowlr" => "Test Bowl R",
"tokyoBridge" => "Tokyo Bridge",
"tokyoDrag" => "Tokyo Drag",
"tokyoGforce" => "Tokyo G-Force Test",
"tokyoLoop" => "Tokyo Loop",
"tokyoOffRamp" => "Tokyo Off-Ramp",
"tokyoOverpass" => "Tokyo Overpass",
"tCircuit" => "Twisty Circuit",
"tCircuitr" => "Twisty Circuit R",
"tRoad" => "Twisty Road",
"waterDrag" => "Waterfront Drag",
_ => track_code,
#[test]
fn test_time_tolerance_constant() {
// Verify the time tolerance is 5ms as required
// We test this indirectly by verifying tie behavior
let ctx = AnalysisContext::new();
let record1 = BestCarRecord {
car_rid: "Car_A".to_string(),
brand: "Brand".to_string(),
only_name: "Model".to_string(),
year: 2020,
rq: 85,
config: "111".to_string(),
time: 100.0,
owned: false,
guid: "guid_a".to_string(),
};
let record2 = BestCarRecord {
car_rid: "Car_B".to_string(),
brand: "Brand".to_string(),
only_name: "Model".to_string(),
year: 2020,
rq: 85,
config: "111".to_string(),
time: 100.003, // Within 0.005 tolerance
owned: false,
guid: "guid_b".to_string(),
};
let record3 = BestCarRecord {
car_rid: "Car_C".to_string(),
brand: "Brand".to_string(),
only_name: "Model".to_string(),
year: 2020,
rq: 85,
config: "111".to_string(),
time: 100.006, // Outside 0.005 tolerance
owned: false,
guid: "guid_c".to_string(),
};
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1);
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2);
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record3);
let best_cars = ctx.best_cars.lock().unwrap();
let key = ("laguna".to_string(), 85, "Dry".to_string());
let records = best_cars.get(&key).unwrap();
// record1 and record2 should both be present (within tolerance)
// record3 should not be added (outside tolerance, and worse time)
assert_eq!(records.len(), 2, "Should have 2 tied records within 0.005 tolerance");
assert!(records.iter().any(|r| r.car_rid == "Car_A"));
assert!(records.iter().any(|r| r.car_rid == "Car_B"));
assert!(!records.iter().any(|r| r.car_rid == "Car_C"));
}
}