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:
parent
6a68469239
commit
89fa69e3c8
14 changed files with 874 additions and 2037 deletions
661
src/club.rs
661
src/club.rs
|
|
@ -1,661 +0,0 @@
|
|||
//! # Top Drives Club Scraper
|
||||
//!
|
||||
//! Alternative data source for the Top Drives Records Analyzer that scrapes car
|
||||
//! performance data from the topdrives.club website. This provides an alternative
|
||||
//! to the JSON-based records system for fetching best car information.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//!
|
||||
//! While the main `records.rs` module uses pre-downloaded JSON timing files,
|
||||
//! this module can fetch fresh data directly from the topdrives.club website
|
||||
//! by scraping HTML tables and parsing car information.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - Scrapes best car data from topdrives.club
|
||||
//! - Supports track-specific car recommendations
|
||||
//! - Configurable RQ ranges and track filtering
|
||||
//! - HTML parsing with fallback error handling
|
||||
//!
|
||||
//! ## Dependencies
|
||||
//!
|
||||
//! - `reqwest`: HTTP client for web scraping
|
||||
//! - `scraper`: HTML parsing and CSS selector library
|
||||
//! - `tokio`: Asynchronous runtime for web requests
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::read_to_string;
|
||||
use std::str::FromStr;
|
||||
|
||||
use tokio;
|
||||
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
/// Represents a car entry scraped from the topdrives.club website
|
||||
///
|
||||
/// Contains the essential car information needed for performance analysis,
|
||||
/// parsed from the HTML tables on the topdrives.club best cars pages.
|
||||
struct Car {
|
||||
/// Car class letter (A, B, C, D, E, S)
|
||||
class: String,
|
||||
/// Rarity level (10-113)
|
||||
rq: u8,
|
||||
/// Car manufacturer/brand name
|
||||
manufacturer: String,
|
||||
/// Car model name
|
||||
model: String,
|
||||
/// Car model year
|
||||
year: u16,
|
||||
/// Upgrade configuration string (e.g., "333", "323")
|
||||
upgrade: String,
|
||||
}
|
||||
/// Fetches HTML content from a URL with error handling
|
||||
///
|
||||
/// Downloads the complete HTML content from the specified URL. Uses a custom
|
||||
/// HTTP client that accepts invalid SSL certificates to handle potential
|
||||
/// certificate issues with the target website.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - The URL to fetch HTML content from
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the HTML content as a String, or an empty string if the request fails.
|
||||
/// Failures are logged to the console but don't cause the program to panic.
|
||||
///
|
||||
/// # Security Note
|
||||
///
|
||||
/// Uses `danger_accept_invalid_certs(true)` to handle websites with certificate
|
||||
/// issues. This should only be used with trusted sources like topdrives.club.
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// Returns empty string on any network or parsing error, allowing the calling
|
||||
/// code to handle the absence of data gracefully.
|
||||
async fn get_page(url: &str) -> String {
|
||||
match reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.unwrap()
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => match response.text().await {
|
||||
Ok(text) => text,
|
||||
Err(error) => {
|
||||
println!("Error getting content! Error: {}", error);
|
||||
String::from("")
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
println!("Error getting content! Error: {}", error);
|
||||
String::from("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Creates mock HTML for testing car parsing
|
||||
fn create_mock_car_html() -> String {
|
||||
r#"
|
||||
<div class="carList__item-manufacturer">Ferrari</div>
|
||||
<div class="carList__item-model">F40</div>
|
||||
<div class="carList__item-year">1987</div>
|
||||
<div class="carList__item-level">
|
||||
<span>S</span>
|
||||
<span>95</span>
|
||||
</div>
|
||||
<div class="carList__item-upgrade">323</div>
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Creates mock HTML with missing elements
|
||||
fn create_incomplete_car_html() -> String {
|
||||
r#"
|
||||
<div class="carList__item-manufacturer">McLaren</div>
|
||||
<div class="carList__item-model">F1</div>
|
||||
<div class="carList__item-year">1993</div>
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Creates mock tracks file content
|
||||
fn create_mock_tracks_content() -> String {
|
||||
"1 Laguna Seca\n2 Monaco Streets\n3 Test Bowl\n".to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_best_car_complete() {
|
||||
let html = create_mock_car_html();
|
||||
let car = parse_best_car(html);
|
||||
|
||||
assert_eq!(car.manufacturer, "Ferrari");
|
||||
assert_eq!(car.model, "F40");
|
||||
assert_eq!(car.year, 1987);
|
||||
assert_eq!(car.class, "S");
|
||||
assert_eq!(car.rq, 95);
|
||||
assert_eq!(car.upgrade, "323");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_best_car_incomplete() {
|
||||
let html = create_incomplete_car_html();
|
||||
let car = parse_best_car(html);
|
||||
|
||||
// Should use default values for missing fields
|
||||
assert_eq!(car.manufacturer, "McLaren");
|
||||
assert_eq!(car.model, "F1");
|
||||
assert_eq!(car.year, 1993);
|
||||
assert_eq!(car.class, "Z"); // Default value
|
||||
assert_eq!(car.rq, 0); // Default value
|
||||
assert_eq!(car.upgrade, "000"); // Default value
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_best_car_empty() {
|
||||
let html = String::new();
|
||||
let car = parse_best_car(html);
|
||||
|
||||
// Should use all default values
|
||||
assert_eq!(car.class, "Z");
|
||||
assert_eq!(car.rq, 0);
|
||||
assert_eq!(car.manufacturer, "");
|
||||
assert_eq!(car.model, "");
|
||||
assert_eq!(car.year, 1900);
|
||||
assert_eq!(car.upgrade, "000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tracks() {
|
||||
let content = create_mock_tracks_content();
|
||||
let tracks = parse_tracks_from_string(content);
|
||||
|
||||
assert_eq!(tracks.len(), 3);
|
||||
assert_eq!(tracks.get(&1), Some(&"Laguna Seca".to_string()));
|
||||
assert_eq!(tracks.get(&2), Some(&"Monaco Streets".to_string()));
|
||||
assert_eq!(tracks.get(&3), Some(&"Test Bowl".to_string()));
|
||||
assert_eq!(tracks.get(&99), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tracks_empty() {
|
||||
let content = String::new();
|
||||
let tracks = parse_tracks_from_string(content);
|
||||
assert_eq!(tracks.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_argument_valid() {
|
||||
let args = vec!["program".to_string(), "85".to_string()];
|
||||
let result = parse_argument(&args, 1, 10, 113);
|
||||
assert_eq!(result, 85);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Argument 1 is out of range")]
|
||||
fn test_parse_argument_too_low() {
|
||||
let args = vec!["program".to_string(), "5".to_string()];
|
||||
parse_argument(&args, 1, 10, 113);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Argument 1 is out of range")]
|
||||
fn test_parse_argument_too_high() {
|
||||
let args = vec!["program".to_string(), "150".to_string()];
|
||||
parse_argument(&args, 1, 10, 113);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Argument 1 could not be parsed")]
|
||||
fn test_parse_argument_invalid() {
|
||||
let args = vec!["program".to_string(), "invalid".to_string()];
|
||||
parse_argument(&args, 1, 10, 113);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_argument_boundary_values() {
|
||||
let args_low = vec!["program".to_string(), "10".to_string()];
|
||||
let result_low = parse_argument(&args_low, 1, 10, 113);
|
||||
assert_eq!(result_low, 10);
|
||||
|
||||
let args_high = vec!["program".to_string(), "113".to_string()];
|
||||
let result_high = parse_argument(&args_high, 1, 10, 113);
|
||||
assert_eq!(result_high, 113);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_best_car_url_construction() {
|
||||
// Test that URL construction follows expected pattern
|
||||
// Note: We can't easily test the actual HTTP request without mocking
|
||||
let track = 1;
|
||||
let track_name = "Laguna Seca".to_string();
|
||||
let max_level = 85;
|
||||
let detailed = false;
|
||||
|
||||
// This would be the expected URL format (for documentation purposes)
|
||||
let _expected_url_pattern = "https://topdrives.club/ajax/load-best-vehicles";
|
||||
|
||||
// In a real test environment, we'd mock the HTTP request
|
||||
// For now, we verify the function doesn't panic with valid inputs
|
||||
let result = get_best_car(track, track_name, max_level, detailed).await;
|
||||
// Result may be empty due to network requirements, but shouldn't panic
|
||||
assert!(result.is_empty() || result.len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_car_struct_creation() {
|
||||
let car = Car {
|
||||
class: "S".to_string(),
|
||||
rq: 95,
|
||||
manufacturer: "Ferrari".to_string(),
|
||||
model: "F40".to_string(),
|
||||
year: 1987,
|
||||
upgrade: "323".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(car.class, "S");
|
||||
assert_eq!(car.rq, 95);
|
||||
assert_eq!(car.manufacturer, "Ferrari");
|
||||
assert_eq!(car.model, "F40");
|
||||
assert_eq!(car.year, 1987);
|
||||
assert_eq!(car.upgrade, "323");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tracks_malformed_lines() {
|
||||
// Test handling of malformed track file lines
|
||||
let content = "1 Laguna Seca\ninvalid line\n2 Monaco Streets\n".to_string();
|
||||
let tracks = parse_tracks_from_string(content);
|
||||
|
||||
// Should only parse valid lines
|
||||
assert_eq!(tracks.len(), 2);
|
||||
assert_eq!(tracks.get(&1), Some(&"Laguna Seca".to_string()));
|
||||
assert_eq!(tracks.get(&2), Some(&"Monaco Streets".to_string()));
|
||||
}
|
||||
|
||||
/// Helper function to test parse_tracks without file I/O
|
||||
fn parse_tracks_from_string(content: String) -> HashMap<u16, String> {
|
||||
let mut result: HashMap<u16, String> = HashMap::new();
|
||||
|
||||
for line in content.lines() {
|
||||
if let Some((index, track)) = line.split_once(' ') {
|
||||
if let Ok(id) = index.parse::<u16>() {
|
||||
result.insert(id, String::from(track));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses HTML content to extract the best car information
|
||||
///
|
||||
/// Uses CSS selectors to extract car details from the HTML table structure
|
||||
/// used by topdrives.club. The function parses manufacturer, model, year,
|
||||
/// class, RQ level, and upgrade configuration from the page content.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `text` - The HTML content containing the car information table
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `Car` struct populated with the parsed information. If parsing
|
||||
/// fails at any step, default values are used to prevent crashes.
|
||||
///
|
||||
/// # Parsing Strategy
|
||||
///
|
||||
/// 1. **Manufacturer**: Extracts from `.carList__item-manufacturer` element
|
||||
/// 2. **Model**: Extracts from `.carList__item-model` element
|
||||
/// 3. **Year**: Extracts from `.carList__item-year` element
|
||||
/// 4. **Class & RQ**: Complex parsing from `.carList__item-level` element
|
||||
/// - Inner HTML contains both class letter and RQ number
|
||||
/// - Uses nested HTML parsing to separate the two values
|
||||
/// 5. **Upgrade**: Extracts from `.carList__item-upgrade` element
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// Uses `unwrap_or` defaults for all fields to ensure the function never
|
||||
/// panics due to malformed HTML or missing elements.
|
||||
fn parse_best_car(text: String) -> Car {
|
||||
// Initialize with default values in case parsing fails
|
||||
let mut car = Car {
|
||||
class: String::from("Z"),
|
||||
rq: 0,
|
||||
manufacturer: String::from(""),
|
||||
model: String::from(""),
|
||||
year: 1900,
|
||||
upgrade: String::from("000"),
|
||||
};
|
||||
|
||||
let document = Html::parse_document(&text);
|
||||
|
||||
// Extract car manufacturer name
|
||||
let manufacturer_selector = Selector::parse(".carList__item-manufacturer").unwrap();
|
||||
car.manufacturer = String::from(match document.select(&manufacturer_selector).next() {
|
||||
None => "",
|
||||
Some(manufacturer) => manufacturer.text().collect::<Vec<_>>()[0],
|
||||
});
|
||||
|
||||
// Extract car model name
|
||||
let model_selector = Selector::parse(".carList__item-model").unwrap();
|
||||
car.model = String::from(match document.select(&model_selector).next() {
|
||||
None => "",
|
||||
Some(model) => model.text().collect::<Vec<_>>()[0],
|
||||
});
|
||||
|
||||
// Extract car model year
|
||||
let year_selector = Selector::parse(".carList__item-year").unwrap();
|
||||
car.year = u16::from_str(match document.select(&year_selector).next() {
|
||||
None => "1900",
|
||||
Some(year) => year.text().collect::<Vec<_>>()[0],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Parse RQ level and class (more complex as they're in the same element)
|
||||
let level_selector = Selector::parse(".carList__item-level").unwrap();
|
||||
let rq_raw = match document.select(&level_selector).next() {
|
||||
Some(element) => element.inner_html(),
|
||||
None => {
|
||||
// If level element is missing, use defaults
|
||||
car.class = "Z".to_string();
|
||||
car.rq = 0;
|
||||
return car;
|
||||
}
|
||||
};
|
||||
|
||||
let rq_fragment = Html::parse_document(rq_raw.trim());
|
||||
|
||||
// Extract class letter and RQ number from nested spans
|
||||
let span_selector = Selector::parse("span").unwrap();
|
||||
let mut element_iterator = rq_fragment.select(&span_selector);
|
||||
let class = String::from(match element_iterator.next() {
|
||||
None => "Z",
|
||||
Some(class) => class.text().collect::<Vec<_>>()[0],
|
||||
});
|
||||
car.class = class.to_uppercase();
|
||||
let rq = match element_iterator.next() {
|
||||
None => "1900",
|
||||
Some(rq) => rq.text().collect::<Vec<_>>()[0],
|
||||
};
|
||||
car.rq = u8::from_str(rq).unwrap();
|
||||
|
||||
// Extract upgrade configuration
|
||||
let upgrade_selector = Selector::parse(".carList__item-upgrade ").unwrap();
|
||||
let upgrade_raw = match document.select(&upgrade_selector).next() {
|
||||
None => "",
|
||||
Some(model) => model.text().collect::<Vec<_>>()[0],
|
||||
};
|
||||
car.upgrade = String::from(upgrade_raw).trim().parse().unwrap();
|
||||
|
||||
car
|
||||
}
|
||||
|
||||
/// Parses the tracks definition file to create track ID to name mappings
|
||||
///
|
||||
/// Reads a text file containing track definitions where each line consists
|
||||
/// of a track ID number followed by the track name. Creates a HashMap for
|
||||
/// efficient track ID to name lookups.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file_name` - Path to the tracks definition file
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `HashMap<u16, String>` mapping track IDs to track names.
|
||||
///
|
||||
/// # File Format
|
||||
///
|
||||
/// The tracks file should have one track per line in the format:
|
||||
/// ```text
|
||||
/// 1 Laguna Seca
|
||||
/// 2 Monaco Streets
|
||||
/// 3 Test Bowl
|
||||
/// ```
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// Uses `unwrap()` for simplicity - in production, proper error handling
|
||||
/// should be implemented for file I/O and parsing errors.
|
||||
fn parse_tracks(file_name: &str) -> HashMap<u16, String> {
|
||||
let mut result: HashMap<u16, String> = HashMap::new();
|
||||
|
||||
// Process each line in the tracks file
|
||||
for line in read_to_string(file_name).unwrap().lines() {
|
||||
let (index, track) = line.split_once(' ').unwrap();
|
||||
result.insert(index.parse::<u16>().unwrap(), String::from(track));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Main entry point for the Top Drives Club scraper
|
||||
///
|
||||
/// Scrapes best car data from topdrives.club for specified tracks and RQ ranges.
|
||||
/// Provides an alternative data source to the JSON-based records system.
|
||||
///
|
||||
/// # Command Line Arguments
|
||||
///
|
||||
/// - No args: Process all tracks with default RQ range (10-113)
|
||||
/// - 1 arg: Set maximum RQ level for all tracks
|
||||
/// - 2 args: Set max RQ and process single track
|
||||
/// - 3 args: Set max RQ, start track, and end track for range processing
|
||||
/// - `-d` flag: Enable detailed output format
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `max_rq` - Maximum RQ level to consider (default: 113)
|
||||
/// * `min_track` - First track ID to process (default: 4)
|
||||
/// * `max_track` - Last track ID to process (default: 278)
|
||||
/// * `detailed` - Whether to show detailed output with upgrade info
|
||||
///
|
||||
/// # Example Usage
|
||||
///
|
||||
/// ```bash
|
||||
/// cargo run --bin club # All tracks, default RQ
|
||||
/// cargo run --bin club -d 100 # All tracks, RQ <= 100, detailed
|
||||
/// cargo run --bin club 85 1 50 # Tracks 1-50, RQ <= 85
|
||||
/// ```
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let mut args: Vec<_> = env::args().collect();
|
||||
|
||||
let mut detailed = false;
|
||||
if let Some(index_detailed) = args.iter().position(|x| *x == String::from("-d")) {
|
||||
detailed = true;
|
||||
args.swap_remove(index_detailed);
|
||||
}
|
||||
|
||||
let mut min_track = 4;
|
||||
let mut max_track = 278;
|
||||
|
||||
let min_rq = 10;
|
||||
let mut max_rq = 113;
|
||||
|
||||
match args.len() {
|
||||
1 => {}
|
||||
2 => max_rq = parse_argument(&args, 1, 10, 113),
|
||||
3 => {
|
||||
max_rq = parse_argument(&args, 1, 10, 113);
|
||||
min_track = parse_argument(&args, 2, 4, 278);
|
||||
max_track = min_track;
|
||||
}
|
||||
4 => {
|
||||
max_rq = parse_argument(&args, 1, 10, 113);
|
||||
min_track = parse_argument(&args, 2, 4, 278);
|
||||
max_track = parse_argument(&args, 3, 4, 278);
|
||||
if max_track < min_track {
|
||||
let temp = min_track;
|
||||
min_track = max_track;
|
||||
max_track = temp
|
||||
}
|
||||
}
|
||||
_ => panic!("Too many arguments."),
|
||||
}
|
||||
|
||||
let tracks = parse_tracks("tracks.txt");
|
||||
|
||||
let mut keepers: HashMap<String, u16> = HashMap::new();
|
||||
|
||||
for track in min_track..max_track + 1 {
|
||||
let track_name = match tracks.get(&track) {
|
||||
None => String::from("NO TRACK NAME"),
|
||||
Some(name) => name.clone(),
|
||||
};
|
||||
let best_car = get_best_car(track, track_name.clone(), max_rq, detailed).await;
|
||||
if best_car.len() > 10 {
|
||||
for level in min_rq..max_rq + 1 {
|
||||
let best_car = get_best_car(track, track_name.clone(), level, detailed).await;
|
||||
if best_car.len() > 10 {
|
||||
// println!("Best Car under RQ {} for track {} ({}): {}", level, track, track_name, best_car);
|
||||
keepers
|
||||
.entry(best_car)
|
||||
.and_modify(|counter| *counter += 1)
|
||||
.or_insert(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// println!("Keepers:");
|
||||
for car in keepers {
|
||||
match detailed {
|
||||
true => println!("{}", car.0),
|
||||
false => println!("{} {}", car.0, car.1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses and validates command line arguments with range checking
|
||||
///
|
||||
/// Validates that numeric arguments are within acceptable ranges and provides
|
||||
/// clear error messages for invalid input. Used for RQ levels and track IDs.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `args` - The command line arguments vector
|
||||
/// * `index` - Index of the argument to parse
|
||||
/// * `min` - Minimum acceptable value
|
||||
/// * `max` - Maximum acceptable value
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the parsed and validated value
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics with descriptive error messages if:
|
||||
/// - The argument cannot be parsed as a number
|
||||
/// - The value is outside the acceptable range
|
||||
///
|
||||
/// # Error Messages
|
||||
///
|
||||
/// Provides clear feedback about expected value ranges and which argument
|
||||
/// position is problematic.
|
||||
fn parse_argument(args: &Vec<String>, index: usize, min: u16, max: u16) -> u16 {
|
||||
match args[index].parse::<u16>() {
|
||||
Ok(parsed_value) => {
|
||||
if parsed_value >= min && parsed_value <= max {
|
||||
parsed_value
|
||||
} else {
|
||||
panic!(
|
||||
"Argument {} is out of range. Please provide a value between {} and {}.",
|
||||
index, min, max
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
panic!("Argument {} could not be parsed. Please provide a positive integer value between {} and {}.", index, min, max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches and parses the best car data for a specific track from topdrives.club
|
||||
///
|
||||
/// Constructs the appropriate URL for the topdrives.club API, fetches the HTML
|
||||
/// response, and parses it to extract the best car information for the given
|
||||
/// track and RQ constraints.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `track` - The track ID number (1-278)
|
||||
/// * `track_name` - Human-readable track name for display
|
||||
/// * `max_level` - Maximum RQ level to consider
|
||||
/// * `detailed` - Whether to include upgrade configuration in output
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a formatted string containing the best car information, or an empty
|
||||
/// string if the request fails or no data is available.
|
||||
///
|
||||
/// # API Details
|
||||
///
|
||||
/// The function constructs a URL with the following parameters:
|
||||
/// - `sort_by=level&sortorder=desc`: Sort by RQ level, highest first
|
||||
/// - `prize=all`: Include all cars, not just prize cars
|
||||
/// - `level=10%3B{max_level}`: RQ range from 10 to max_level
|
||||
/// - `acceleration=1%3B50&grip=40%3B110`: Performance filters
|
||||
/// - `year=1900%3B2030`: Year range filter
|
||||
/// - `seats_count=1%3B9`: Include all seat counts
|
||||
/// - `track={track}`: Specific track ID
|
||||
/// - `page=0`: First page of results
|
||||
///
|
||||
/// # Output Formats
|
||||
///
|
||||
/// **Detailed mode:**
|
||||
/// ```text
|
||||
/// S85 Ferrari 250 GTO 1962: Laguna Seca (333)
|
||||
/// ```
|
||||
///
|
||||
/// **Normal mode:**
|
||||
/// ```text
|
||||
/// S85 Ferrari 250 GTO 1962
|
||||
/// ```
|
||||
async fn get_best_car(track: u16, track_name: String, max_level: u16, detailed: bool) -> String {
|
||||
// Construct the API URL with all necessary parameters
|
||||
let url = format!("https://topdrives.club/ajax/load-best-vehicles?sort_by=level&sortorder=desc&prize=all&level=10%3B{}&acceleration=1%3B50&grip=40%3B110&year=1900%3B2030&seats_count=1%3B9&track={}&page=0",
|
||||
max_level, track);
|
||||
// println!("Getting {}", url);
|
||||
let text = get_page(&url).await;
|
||||
|
||||
if text.len() > 10 {
|
||||
let best_car = parse_best_car(text);
|
||||
|
||||
match detailed {
|
||||
true => {
|
||||
format!(
|
||||
"{}{} {} {} {}: {} ({})",
|
||||
best_car.class, // Car class (S, A, B, etc.)
|
||||
best_car.rq, // RQ level
|
||||
best_car.manufacturer, // Brand name
|
||||
best_car.model, // Model name
|
||||
best_car.year, // Model year
|
||||
track_name, // Track name
|
||||
best_car.upgrade, // Upgrade configuration
|
||||
)
|
||||
}
|
||||
false => {
|
||||
format!(
|
||||
"{}{} {} {} {}",
|
||||
best_car.class, // Car class
|
||||
best_car.rq, // RQ level
|
||||
best_car.manufacturer, // Brand name
|
||||
best_car.model, // Model name
|
||||
best_car.year, // Model year
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No data received or request failed
|
||||
String::from("")
|
||||
}
|
||||
}
|
||||
317
src/lib.rs
Normal file
317
src/lib.rs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
//! # Top Drives Shared Library
|
||||
//!
|
||||
//! Shared types and logic used by the `records` binary.
|
||||
//!
|
||||
//! ## Re-exports
|
||||
//!
|
||||
//! - **`Car` struct** — core car information from the records database
|
||||
//! - **`BestCarRecord` struct** — best performing car record for a specific track/RQ/condition
|
||||
//! - **`TripletKey` type alias** — composite key `(track_id, rq, surface)`
|
||||
//! - **`AnalysisContext` struct** — holds mutable analysis state (replaces global static)
|
||||
//! - **`MyCarsData` struct** — parsed user data from `my_cars.json`
|
||||
//! - **`translate_track_name()`** — internal track code → human-readable name
|
||||
//! - **`translate_track_type()`** — condition code → surface name
|
||||
//! - **`get_file()`** — async file download utility
|
||||
//! - **`load_my_cars_data()`** — loads and parses `my_cars.json`
|
||||
//! - **`compare_cars()`** — sorting comparator for `Car`
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
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)]
|
||||
pub struct Car {
|
||||
/// Unique car identifier used in the records database
|
||||
pub rid: String,
|
||||
/// Car manufacturer/brand name
|
||||
pub brand: String,
|
||||
/// Car model name (without brand)
|
||||
pub only_name: String,
|
||||
/// Car model year
|
||||
pub year: u16,
|
||||
/// Rarity level (10-113, affects performance brackets)
|
||||
pub rq: u8,
|
||||
/// Whether this car is only available as a prize car
|
||||
pub prize: bool,
|
||||
/// Game-specific unique identifier for the car
|
||||
pub guid: String,
|
||||
}
|
||||
|
||||
/// Represents the best performing car record for a specific track, RQ, and condition.
|
||||
/// Contains all necessary information to compare car performance.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BestCarRecord {
|
||||
/// Car identifier from the records database
|
||||
pub car_rid: String,
|
||||
/// Car manufacturer/brand name
|
||||
pub brand: String,
|
||||
/// Car model name (without brand)
|
||||
pub only_name: String,
|
||||
/// Car model year
|
||||
pub year: u16,
|
||||
/// Rarity level of this specific car configuration
|
||||
pub rq: u8,
|
||||
/// Tune configuration string (e.g., "111", "233", "323")
|
||||
pub config: String,
|
||||
/// Track time in seconds (lower is better)
|
||||
pub time: f64,
|
||||
/// Whether the current user owns this car
|
||||
pub owned: bool,
|
||||
/// Game-specific unique identifier for the car
|
||||
pub guid: String,
|
||||
}
|
||||
|
||||
/// Type alias for the composite key used to identify unique track conditions.
|
||||
/// Consists of: (Track ID, RQ Level, Surface Condition)
|
||||
pub type TripletKey = (String, u8, String);
|
||||
|
||||
/// Holds the mutable analysis state for a records analysis run.
|
||||
///
|
||||
/// Replaces the former global `static BEST_CARS`. Created in `main()` and
|
||||
/// passed through the call chain so that no global mutable state is needed.
|
||||
pub struct AnalysisContext {
|
||||
/// Best car records for each track/RQ/surface combination, protected by
|
||||
/// `Arc<Mutex<...>>` for concurrent access from spawned tasks.
|
||||
pub best_cars: Arc<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
|
||||
}
|
||||
|
||||
impl AnalysisContext {
|
||||
/// Create a new, empty analysis context.
|
||||
pub fn new() -> Self {
|
||||
AnalysisContext {
|
||||
best_cars: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub struct MyCarsData {
|
||||
/// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`)
|
||||
pub owned: HashSet<String>,
|
||||
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
|
||||
pub 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.
|
||||
pub 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.
|
||||
pub 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))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub 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
|
||||
match response.text().await {
|
||||
Ok(content) => {
|
||||
if let Err(e) = fs::write(local_file_path, content) {
|
||||
eprintln!("Error writing {local_file_path}: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error reading response from {file_url}: {e}"),
|
||||
}
|
||||
}
|
||||
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".
|
||||
pub 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn translate_track_name(track_code: &str) -> String {
|
||||
static TRACK_NAMES: OnceLock<HashMap<String, String>> = OnceLock::new();
|
||||
|
||||
let map = TRACK_NAMES.get_or_init(|| {
|
||||
let json_str = include_str!("../track_names.json");
|
||||
let raw: HashMap<String, String> =
|
||||
serde_json::from_str(json_str).expect("track_names.json should be valid JSON");
|
||||
raw
|
||||
});
|
||||
|
||||
map.get(track_code)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| track_code.to_string())
|
||||
}
|
||||
675
src/records.rs
675
src/records.rs
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue