Refactor: extract lib.rs into focused sub-modules, add --output-dir CLI option
- Split monolithic src/lib.rs (~640 lines) into 7 sub-modules: types.rs, context.rs, user_data.rs, translate.rs, http.rs, csv_util.rs, test_utils.rs - Add configurable --output-dir CLI option for CSV output files - Implement atomic file writes (write-to-temp + rename) for all CSVs - Change compute_car_counts() to return CarCounts directly (not Result) - Add test-utils feature gate for shared test helpers - Add HTTP mock tests with mockito (6 tests) - Add proptest tests for compute_car_counts (3 property tests) - Update integration tests to use shared test utilities - Update documentation (API.md, ARCHITECTURE.md, DATA_STRUCTURES.md)
This commit is contained in:
parent
d2c1418e5e
commit
67a79f190a
27 changed files with 1648 additions and 789 deletions
654
src/lib.rs
654
src/lib.rs
|
|
@ -17,637 +17,33 @@
|
|||
//! - **`load_my_cars_data()`** — loads and parses `my_cars.json`
|
||||
//! - **`compare_cars()`** — sorting comparator for `Car`
|
||||
//! - **`compute_car_counts()`** — aggregates per-car win counts from analysis context
|
||||
//! - **`escape_csv_field()`** — CSV-safe field escaping
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
// Sub-modules
|
||||
pub mod context;
|
||||
pub mod csv_util;
|
||||
pub mod http;
|
||||
pub mod translate;
|
||||
pub mod types;
|
||||
pub mod user_data;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use reqwest::get;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Custom error type for the Top Drives application.
|
||||
/// Shared test utilities.
|
||||
///
|
||||
/// Provides a unified error handling strategy across all modules,
|
||||
/// replacing the mix of `Result<(), String>`, `std::io::Result`, and
|
||||
/// silent `eprintln!` + early return patterns.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
/// An I/O error occurred (file not found, permission denied, etc.)
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// A network error occurred during data fetching
|
||||
#[error("Network error: {0}")]
|
||||
Network(String),
|
||||
|
||||
/// A JSON parsing error in a specific file
|
||||
#[error("Parse error in {file}: {reason}")]
|
||||
Parse {
|
||||
/// The file that failed to parse
|
||||
file: String,
|
||||
/// The reason for the parse failure
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// A required data file is missing
|
||||
#[error("Missing data file: {0}")]
|
||||
MissingFile(String),
|
||||
|
||||
/// An internal runtime error (e.g., semaphore closed, task join failure)
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Core car information structure containing basic car metadata.
|
||||
/// Used throughout the application to represent car entities.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
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)
|
||||
#[serde(rename = "onlyName")]
|
||||
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
|
||||
#[serde(default)]
|
||||
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,
|
||||
/// Whether this car is only available as a prize car
|
||||
pub prize: 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.
|
||||
/// Intended for use in unit and integration tests. Provides helper constructors
|
||||
/// for [`BestCarRecord`] and [`Car`] to reduce boilerplate in test code.
|
||||
///
|
||||
/// Replaces the former global `static BEST_CARS`. Created in `main()` and
|
||||
/// passed through the call chain so that no global mutable state is needed.
|
||||
///
|
||||
/// Uses `Arc<DashMap>` for concurrent access from spawned tasks.
|
||||
/// `DashMap` provides sharded internal locking for high-concurrency access.
|
||||
pub struct AnalysisContext {
|
||||
/// Best car records for each track/RQ/surface combination.
|
||||
/// Wrapped in `Arc` so it can be shared across async tasks.
|
||||
pub best_cars: Arc<DashMap<TripletKey, Vec<BestCarRecord>>>,
|
||||
}
|
||||
/// Enabled by the `test-utils` feature flag. This keeps test helpers out of
|
||||
/// the library's semver public API while remaining accessible to downstream
|
||||
/// test crates via `top_drives = { features = ["test-utils"] }`.
|
||||
#[cfg(feature = "test-utils")]
|
||||
#[doc(hidden)]
|
||||
pub mod test_utils;
|
||||
|
||||
impl AnalysisContext {
|
||||
/// Create a new, empty analysis context.
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new() -> Self {
|
||||
AnalysisContext {
|
||||
best_cars: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated per-car win counts across all track/RQ/condition combinations.
|
||||
/// Computed once from `AnalysisContext` and shared across all output functions.
|
||||
pub struct CarCounts {
|
||||
/// Map from car_rid → (BestCarRecord, count of tracks where this car is best)
|
||||
pub by_rid: HashMap<String, (BestCarRecord, usize)>,
|
||||
/// Map from guid → car_rid (for O(1) ownership/wishlist lookups)
|
||||
pub guid_to_rid: HashMap<String, String>,
|
||||
/// Map from guid → Car (for O(1) lookups of car metadata by GUID).
|
||||
/// Built once in `compute_car_counts()` and reused by trash/wishlist output.
|
||||
pub car_map: HashMap<String, Car>,
|
||||
/// Full car list from the database (kept for any callers that need iteration).
|
||||
pub car_list: Vec<Car>,
|
||||
}
|
||||
|
||||
/// Computes per-car win counts from the analysis context.
|
||||
/// Returns a `CarCounts` struct that can be shared across all output functions.
|
||||
pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec<Car>) -> Result<CarCounts, AppError> {
|
||||
let mut by_rid: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
|
||||
let mut guid_to_rid: HashMap<String, String> = HashMap::new();
|
||||
|
||||
for entry in ctx.best_cars.iter() {
|
||||
for best_car in entry.value() {
|
||||
let rid_entry = by_rid
|
||||
.entry(best_car.car_rid.clone())
|
||||
.or_insert_with(|| (best_car.clone(), 0usize));
|
||||
rid_entry.1 += 1;
|
||||
// Keep the record with the best (lowest) time for this car.
|
||||
if best_car.time < rid_entry.0.time {
|
||||
rid_entry.0 = best_car.clone();
|
||||
}
|
||||
// HashMap iteration order is non-deterministic, but car_rid and guid
|
||||
// have a 1:1 relationship in the data model, so any mapping is correct.
|
||||
guid_to_rid.entry(best_car.guid.clone())
|
||||
.or_insert_with(|| best_car.car_rid.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Build guid → Car lookup once, reused by trash/wishlist output functions.
|
||||
let car_map: HashMap<String, Car> = car_list.iter()
|
||||
.map(|c| (c.guid.clone(), c.clone()))
|
||||
.collect();
|
||||
|
||||
Ok(CarCounts { by_rid, guid_to_rid, car_map, car_list })
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Debug, Default)]
|
||||
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 `AppError` if the file cannot be opened or parsed.
|
||||
/// Use `MyCarsData::default()` for graceful degradation when the file is optional.
|
||||
pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
|
||||
let file = File::open(path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
AppError::MissingFile(format!("{path}: {e}"))
|
||||
} else {
|
||||
AppError::Io(e)
|
||||
}
|
||||
})?;
|
||||
let reader = BufReader::new(file);
|
||||
let json: Value = serde_json::from_reader(reader)
|
||||
.map_err(|e| AppError::Parse { file: path.to_string(), reason: e.to_string() })?;
|
||||
|
||||
// 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();
|
||||
|
||||
Ok(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.
|
||||
///
|
||||
/// Returns `Ok(())` on success, or an error describing what went wrong.
|
||||
///
|
||||
/// Writes to a temporary file first, then renames on success, so a failed
|
||||
/// download never deletes the existing file.
|
||||
///
|
||||
/// Retries up to `MAX_FETCH_RETRIES` times on transient failures (server errors,
|
||||
/// connection errors, or empty responses). Permanent HTTP errors (4xx) fail immediately.
|
||||
pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppError> {
|
||||
/// Maximum number of retries when the server returns an empty response.
|
||||
const MAX_FETCH_RETRIES: usize = 3;
|
||||
|
||||
let tmp_path = format!("{local_file_path}.tmp");
|
||||
let _ = fs::remove_file(&tmp_path); // clean stale temp file, OK to ignore
|
||||
|
||||
let mut attempt = 0usize;
|
||||
loop {
|
||||
match get(file_url).await {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
// Only retry on server errors (5xx); client errors (4xx) are permanent.
|
||||
if status.is_server_error() && attempt < MAX_FETCH_RETRIES {
|
||||
eprintln!(
|
||||
"Warning: HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
|
||||
status,
|
||||
attempt + 1,
|
||||
);
|
||||
tokio::time::sleep(
|
||||
std::time::Duration::from_millis(500 * (attempt + 1) as u64)
|
||||
).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(AppError::Network(
|
||||
format!("HTTP {status} from {file_url}")
|
||||
));
|
||||
}
|
||||
match response.text().await {
|
||||
Ok(content) => {
|
||||
if content.is_empty() {
|
||||
if attempt < MAX_FETCH_RETRIES {
|
||||
eprintln!(
|
||||
"Warning: empty response from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
|
||||
attempt + 1,
|
||||
);
|
||||
tokio::time::sleep(
|
||||
std::time::Duration::from_millis(500 * (attempt + 1) as u64)
|
||||
).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(AppError::Network(
|
||||
format!("Empty response from {file_url} after {MAX_FETCH_RETRIES} retries")
|
||||
));
|
||||
}
|
||||
fs::write(&tmp_path, &content).map_err(AppError::Io)?;
|
||||
return fs::rename(&tmp_path, local_file_path).map_err(AppError::Io);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(AppError::Network(
|
||||
format!("Error reading response from {file_url}: {e}")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
if attempt < MAX_FETCH_RETRIES {
|
||||
eprintln!(
|
||||
"Warning: failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}",
|
||||
attempt + 1,
|
||||
);
|
||||
tokio::time::sleep(
|
||||
std::time::Duration::from_millis(500 * (attempt + 1) as u64)
|
||||
).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(AppError::Network(
|
||||
format!("Failed to fetch {file_url} after {MAX_FETCH_RETRIES} retries: {error}")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// Escapes a field for safe inclusion in a CSV file.
|
||||
///
|
||||
/// If the field contains a comma, quote, newline, or carriage return,
|
||||
/// it is wrapped in double-quotes with internal quotes doubled.
|
||||
pub fn escape_csv_field(field: &str) -> String {
|
||||
if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
|
||||
format!("\"{}\"", field.replace('"', "\"\""))
|
||||
} else {
|
||||
field.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_translate_track_name() {
|
||||
assert_eq!(translate_track_name("laguna"), "Laguna Seca");
|
||||
assert_eq!(translate_track_name("mnCity"), "Monaco Streets");
|
||||
assert_eq!(translate_track_name("testBowl"), "Test Bowl");
|
||||
assert_eq!(translate_track_name("unknown_track"), "unknown_track");
|
||||
assert_eq!(translate_track_name("drag100"), "0-100mph");
|
||||
assert_eq!(translate_track_name("mile4"), "1/4 Mile Drag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_track_type() {
|
||||
assert_eq!(translate_track_type("00"), "Dry");
|
||||
assert_eq!(translate_track_type("01"), "Wet");
|
||||
assert_eq!(translate_track_type("10"), "Dirt");
|
||||
assert_eq!(translate_track_type("11"), "Dirt Wet");
|
||||
assert_eq!(translate_track_type("30"), "Ice");
|
||||
assert_eq!(translate_track_type("60"), "Snow");
|
||||
assert_eq!(translate_track_type("99"), "99");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_track_name_completeness() {
|
||||
let test_tracks = vec![
|
||||
"laguna",
|
||||
"mnCity",
|
||||
"testBowl",
|
||||
"drag100",
|
||||
"mile4",
|
||||
"airplaneHangars",
|
||||
"dealsGap",
|
||||
"northloop",
|
||||
];
|
||||
|
||||
for track in test_tracks {
|
||||
let name = translate_track_name(track);
|
||||
assert_ne!(name, track, "Track {} should have a proper name mapping", track);
|
||||
assert!(!name.is_empty(), "Track name should not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_condition_completeness() {
|
||||
let test_conditions = vec!["00", "01", "10", "11", "20", "30", "40", "41", "50", "60"];
|
||||
|
||||
for condition in test_conditions {
|
||||
let name = translate_track_type(condition);
|
||||
assert_ne!(name, condition, "Condition {} should have a proper name mapping", condition);
|
||||
assert!(!name.is_empty(), "Condition name should not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compare_cars_ordering() {
|
||||
let car_a = Car {
|
||||
rid: "A".to_string(), brand: "BMW".to_string(), only_name: "M3".to_string(),
|
||||
year: 2020, rq: 80, prize: false, guid: "guid_a".to_string(),
|
||||
};
|
||||
let car_b = Car {
|
||||
rid: "B".to_string(), brand: "Audi".to_string(), only_name: "RS6".to_string(),
|
||||
year: 2021, rq: 90, prize: false, guid: "guid_b".to_string(),
|
||||
};
|
||||
let car_c = Car {
|
||||
rid: "C".to_string(), brand: "BMW".to_string(), only_name: "M5".to_string(),
|
||||
year: 2022, rq: 80, prize: false, guid: "guid_c".to_string(),
|
||||
};
|
||||
|
||||
// Lower RQ sorts first
|
||||
assert_eq!(compare_cars(&car_a, &car_b), std::cmp::Ordering::Less);
|
||||
// Same RQ, same brand, then model alphabetically (M3 < M5)
|
||||
assert_eq!(compare_cars(&car_a, &car_c), std::cmp::Ordering::Less);
|
||||
// Higher RQ sorts last
|
||||
assert_eq!(compare_cars(&car_b, &car_a), std::cmp::Ordering::Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_car_counts_basic() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let record = BestCarRecord {
|
||||
car_rid: "Test_Car".to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.0,
|
||||
owned: true,
|
||||
prize: false,
|
||||
guid: "guid_test".to_string(),
|
||||
};
|
||||
|
||||
// Manually insert into best_cars
|
||||
{
|
||||
ctx.best_cars.insert(
|
||||
("Laguna Seca".to_string(), 85, "Dry".to_string()),
|
||||
vec![record.clone()],
|
||||
);
|
||||
ctx.best_cars.insert(
|
||||
("Monaco Streets".to_string(), 85, "Dry".to_string()),
|
||||
vec![record.clone()],
|
||||
);
|
||||
}
|
||||
|
||||
let car_list = vec![Car {
|
||||
rid: "Test_Car".to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
prize: false,
|
||||
guid: "guid_test".to_string(),
|
||||
}];
|
||||
|
||||
let counts = compute_car_counts(&ctx, car_list).unwrap();
|
||||
|
||||
// Should have exactly 1 entry in by_rid
|
||||
assert_eq!(counts.by_rid.len(), 1);
|
||||
let (rec, count) = counts.by_rid.get("Test_Car").unwrap();
|
||||
assert_eq!(count, &2); // best on 2 tracks
|
||||
assert_eq!(rec.car_rid, "Test_Car");
|
||||
|
||||
// guid_to_rid should map guid → rid
|
||||
assert_eq!(counts.guid_to_rid.get("guid_test"), Some(&"Test_Car".to_string()));
|
||||
|
||||
// car_map should have the car
|
||||
assert!(counts.car_map.contains_key("guid_test"));
|
||||
assert_eq!(counts.car_list.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_car_counts_keeps_best_time() {
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
let record_fast = 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: 90.0,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: "guid_a".to_string(),
|
||||
};
|
||||
let record_slow = BestCarRecord {
|
||||
car_rid: "Car_A".to_string(),
|
||||
brand: "Brand".to_string(),
|
||||
only_name: "Model".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "233".to_string(),
|
||||
time: 110.0,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: "guid_a".to_string(),
|
||||
};
|
||||
|
||||
{
|
||||
ctx.best_cars.insert(
|
||||
("Track1".to_string(), 85, "Dry".to_string()),
|
||||
vec![record_fast],
|
||||
);
|
||||
ctx.best_cars.insert(
|
||||
("Track2".to_string(), 85, "Dry".to_string()),
|
||||
vec![record_slow],
|
||||
);
|
||||
}
|
||||
|
||||
let counts = compute_car_counts(&ctx, vec![]).unwrap();
|
||||
let (rec, count) = counts.by_rid.get("Car_A").unwrap();
|
||||
assert_eq!(*count, 2);
|
||||
assert_eq!(rec.time, 90.0); // Should keep the faster time
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_csv_field() {
|
||||
// Simple field — no escaping needed
|
||||
assert_eq!(escape_csv_field("hello"), "hello");
|
||||
// Field with comma
|
||||
assert_eq!(escape_csv_field("hello,world"), "\"hello,world\"");
|
||||
// Field with quote
|
||||
assert_eq!(escape_csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
|
||||
// Field with newline
|
||||
assert_eq!(escape_csv_field("line1\nline2"), "\"line1\nline2\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_my_cars_data_missing_file() {
|
||||
let result = load_my_cars_data("nonexistent_file.json");
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
AppError::MissingFile(msg) => assert!(msg.contains("nonexistent_file.json")),
|
||||
other => panic!("Expected MissingFile error, got: {other}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-export all public types and functions for backward compatibility.
|
||||
// External code can use `top_drives::Car` etc. without knowing the internal module layout.
|
||||
pub use context::{AnalysisContext, CarCounts, compute_car_counts};
|
||||
pub use csv_util::{compare_cars, escape_csv_field};
|
||||
pub use http::get_file;
|
||||
pub use translate::{translate_track_name, translate_track_type};
|
||||
pub use types::{AppError, BestCarRecord, Car, TripletKey};
|
||||
pub use user_data::{MyCarsData, load_my_cars_data};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue