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
189
src/context.rs
Normal file
189
src/context.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
//! Analysis context and aggregated car counts.
|
||||
//!
|
||||
//! Contains the mutable analysis state (`AnalysisContext`) and the
|
||||
//! aggregated per-car win counts (`CarCounts`) computed from it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
|
||||
use crate::types::{BestCarRecord, Car, TripletKey};
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Uses `Arc<DashMap>` for concurrent access from spawned tasks.
|
||||
/// `Arc::clone` only increments the reference count, so all tasks share
|
||||
/// the same underlying map.
|
||||
pub struct AnalysisContext {
|
||||
/// Best car records for each track/RQ/surface combination.
|
||||
/// Wrapped in `Arc` so it can be cheaply shared across async tasks.
|
||||
pub best_cars: Arc<DashMap<TripletKey, Vec<BestCarRecord>>>,
|
||||
}
|
||||
|
||||
impl Default for AnalysisContext {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AnalysisContext {
|
||||
/// Create a new, empty analysis context.
|
||||
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.
|
||||
/// This function cannot fail — it only performs `DashMap` iteration and
|
||||
/// `HashMap` inserts.
|
||||
pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec<Car>) -> CarCounts {
|
||||
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();
|
||||
|
||||
CarCounts { by_rid, guid_to_rid, car_map, car_list }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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);
|
||||
|
||||
// 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![]);
|
||||
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue