feat: add normalized config tracking and improve command
- Add normalize_to_final_build() to map tune configs to final builds (323/233/332) - Add normalized_config field to BestCarRecord - Change CarCounts.by_rid key from String to (String, String) for per-config tracking - Change CarCounts.guid_to_rid to HashMap<String, HashSet<(String, String)>> - Extract owned tune configs from my_cars.json (engineMajor/weightMajor/chassisMajor) - Add improve command: owned cars not yet at final build, sorted by track count - Strip version prefixes (v2c_/v1c_) from config keys at parse time - Update dedup check in update_best_car to consider normalized_config - Add atomic_write helper for safe CSV output - Delete completed plans/code-review-v2.md - Apply rustfmt formatting across codebase
This commit is contained in:
parent
67a79f190a
commit
bf21d3d1f6
25 changed files with 735 additions and 759 deletions
|
|
@ -2,22 +2,17 @@
|
|||
|
||||
use std::fs;
|
||||
|
||||
use mockito::{Server, ServerGuard, Mock};
|
||||
use mockito::{Mock, Server, ServerGuard};
|
||||
use top_drives::get_file;
|
||||
|
||||
/// Helper: create a mock that returns a successful response with the given body.
|
||||
fn mock_success(server: &mut ServerGuard, body: &str) -> Mock {
|
||||
server.mock("GET", "/data.json")
|
||||
.with_status(200)
|
||||
.with_body(body)
|
||||
.create()
|
||||
server.mock("GET", "/data.json").with_status(200).with_body(body).create()
|
||||
}
|
||||
|
||||
/// Helper: create a mock that returns the given HTTP status.
|
||||
fn mock_status(server: &mut ServerGuard, status: usize) -> Mock {
|
||||
server.mock("GET", "/data.json")
|
||||
.with_status(status)
|
||||
.create()
|
||||
server.mock("GET", "/data.json").with_status(status).create()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -39,7 +34,8 @@ async fn test_get_file_success() {
|
|||
async fn test_get_file_empty_response_retries_and_fails() {
|
||||
let mut server = Server::new_async().await;
|
||||
// Return 200 with empty body — triggers retry, then fails
|
||||
let m = server.mock("GET", "/data.json")
|
||||
let m = server
|
||||
.mock("GET", "/data.json")
|
||||
.with_status(200)
|
||||
.with_body("")
|
||||
.expect(4) // 1 initial + 3 retries
|
||||
|
|
@ -58,10 +54,7 @@ async fn test_get_file_empty_response_retries_and_fails() {
|
|||
async fn test_get_file_server_error_retries() {
|
||||
let mut server = Server::new_async().await;
|
||||
// Return 500 three times, then 200
|
||||
let m_fail = server.mock("GET", "/data.json")
|
||||
.with_status(500)
|
||||
.expect(3)
|
||||
.create();
|
||||
let m_fail = server.mock("GET", "/data.json").with_status(500).expect(3).create();
|
||||
let m_ok = mock_success(&mut server, "recovered");
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -96,7 +89,8 @@ async fn test_get_file_client_error_fails_immediately() {
|
|||
async fn test_get_file_persistent_server_error_fails() {
|
||||
let mut server = Server::new_async().await;
|
||||
// 503 every time — should retry 3 times then fail
|
||||
let m = server.mock("GET", "/data.json")
|
||||
let m = server
|
||||
.mock("GET", "/data.json")
|
||||
.with_status(503)
|
||||
.expect(4) // 1 initial + 3 retries
|
||||
.create();
|
||||
|
|
|
|||
|
|
@ -6,12 +6,11 @@
|
|||
use std::fs;
|
||||
use std::io::Write;
|
||||
|
||||
use top_drives::test_utils::{make_car, make_record};
|
||||
use top_drives::{
|
||||
AnalysisContext, BestCarRecord, Car,
|
||||
compare_cars, compute_car_counts, escape_csv_field, load_my_cars_data,
|
||||
translate_track_name, translate_track_type,
|
||||
compare_cars, compute_car_counts, empty_my_data, escape_csv_field, load_my_cars_data,
|
||||
translate_track_name, translate_track_type, AnalysisContext, BestCarRecord, Car,
|
||||
};
|
||||
use top_drives::test_utils::{make_record, make_car};
|
||||
|
||||
// =======================================================================
|
||||
// Integration: AnalysisContext → CarCounts pipeline
|
||||
|
|
@ -21,7 +20,7 @@ use top_drives::test_utils::{make_record, make_car};
|
|||
fn test_full_pipeline_empty_input() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let car_list: Vec<Car> = vec![];
|
||||
let counts = compute_car_counts(&ctx, car_list);
|
||||
let counts = compute_car_counts(&ctx, car_list, empty_my_data());
|
||||
|
||||
assert!(counts.by_rid.is_empty());
|
||||
assert!(counts.guid_to_rid.is_empty());
|
||||
|
|
@ -35,25 +34,21 @@ fn test_full_pipeline_single_car_multiple_tracks() {
|
|||
|
||||
// Simulate the car being best on 20 tracks
|
||||
for i in 0..20 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 95, "Dry".to_string()),
|
||||
vec![rec.clone()],
|
||||
);
|
||||
ctx.best_cars.insert((format!("Track{i}"), 95, "Dry".to_string()), vec![rec.clone()]);
|
||||
}
|
||||
|
||||
let car = make_car("Ferrari_F40", 95, "guid_f40");
|
||||
let counts = compute_car_counts(&ctx, vec![car]);
|
||||
let counts = compute_car_counts(&ctx, vec![car], empty_my_data());
|
||||
|
||||
assert_eq!(counts.by_rid.len(), 1);
|
||||
let (rec, count) = counts.by_rid.get("Ferrari_F40").unwrap();
|
||||
let (rec, count) = counts.by_rid.get(&("Ferrari_F40".to_string(), "332".to_string())).unwrap();
|
||||
assert_eq!(*count, 20);
|
||||
assert_eq!(rec.time, 88.5);
|
||||
|
||||
// Verify guid_to_rid mapping
|
||||
assert_eq!(
|
||||
counts.guid_to_rid.get("guid_f40"),
|
||||
Some(&"Ferrari_F40".to_string())
|
||||
);
|
||||
let guid_entries = counts.guid_to_rid.get("guid_f40").unwrap();
|
||||
assert_eq!(guid_entries.len(), 1);
|
||||
assert!(guid_entries.contains(&("Ferrari_F40".to_string(), "332".to_string())));
|
||||
|
||||
// Verify car_map
|
||||
assert!(counts.car_map.contains_key("guid_f40"));
|
||||
|
|
@ -66,28 +61,19 @@ fn test_full_pipeline_multiple_cars_different_rq() {
|
|||
// Car A: RQ 80, best on 5 tracks
|
||||
let rec_a = make_record("Car_A", 80, 100.0, "guid_a");
|
||||
for i in 0..5 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 80, "Dry".to_string()),
|
||||
vec![rec_a.clone()],
|
||||
);
|
||||
ctx.best_cars.insert((format!("Track{i}"), 80, "Dry".to_string()), vec![rec_a.clone()]);
|
||||
}
|
||||
|
||||
// Car B: RQ 90, best on 12 tracks
|
||||
let rec_b = make_record("Car_B", 90, 95.0, "guid_b");
|
||||
for i in 0..12 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("TrackB{i}"), 90, "Dry".to_string()),
|
||||
vec![rec_b.clone()],
|
||||
);
|
||||
ctx.best_cars.insert((format!("TrackB{i}"), 90, "Dry".to_string()), vec![rec_b.clone()]);
|
||||
}
|
||||
|
||||
// Car C: RQ 100, best on 8 tracks
|
||||
let rec_c = make_record("Car_C", 100, 85.0, "guid_c");
|
||||
for i in 0..8 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("TrackC{i}"), 100, "Dry".to_string()),
|
||||
vec![rec_c.clone()],
|
||||
);
|
||||
ctx.best_cars.insert((format!("TrackC{i}"), 100, "Dry".to_string()), vec![rec_c.clone()]);
|
||||
}
|
||||
|
||||
let car_list = vec![
|
||||
|
|
@ -96,17 +82,17 @@ fn test_full_pipeline_multiple_cars_different_rq() {
|
|||
make_car("Car_C", 100, "guid_c"),
|
||||
];
|
||||
|
||||
let counts = compute_car_counts(&ctx, car_list);
|
||||
let counts = compute_car_counts(&ctx, car_list, empty_my_data());
|
||||
|
||||
assert_eq!(counts.by_rid.len(), 3);
|
||||
|
||||
let (_, count_a) = counts.by_rid.get("Car_A").unwrap();
|
||||
let (_, count_a) = counts.by_rid.get(&("Car_A".to_string(), "332".to_string())).unwrap();
|
||||
assert_eq!(*count_a, 5);
|
||||
|
||||
let (_, count_b) = counts.by_rid.get("Car_B").unwrap();
|
||||
let (_, count_b) = counts.by_rid.get(&("Car_B".to_string(), "332".to_string())).unwrap();
|
||||
assert_eq!(*count_b, 12);
|
||||
|
||||
let (_, count_c) = counts.by_rid.get("Car_C").unwrap();
|
||||
let (_, count_c) = counts.by_rid.get(&("Car_C".to_string(), "332".to_string())).unwrap();
|
||||
assert_eq!(*count_c, 8);
|
||||
}
|
||||
|
||||
|
|
@ -127,6 +113,7 @@ fn test_csv_output_field_ordering() {
|
|||
year: 2023,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
normalized_config: "332".to_string(),
|
||||
time: 100.0,
|
||||
owned: true,
|
||||
prize: false,
|
||||
|
|
@ -137,15 +124,16 @@ fn test_csv_output_field_ordering() {
|
|||
// Write with the standard format
|
||||
{
|
||||
let mut file = fs::File::create(path_str).unwrap();
|
||||
writeln!(file, "RQ,Brand,Model,Year,Count").unwrap();
|
||||
writeln!(file, "RQ,Brand,Model,Year,FinalBuild,Count").unwrap();
|
||||
for (rec, count) in &entries {
|
||||
writeln!(
|
||||
file,
|
||||
"{},{},{},{},{}",
|
||||
"{},{},{},{},{},{}",
|
||||
rec.rq,
|
||||
escape_csv_field(&rec.brand),
|
||||
escape_csv_field(&rec.only_name),
|
||||
rec.year,
|
||||
rec.normalized_config,
|
||||
count
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -154,8 +142,8 @@ fn test_csv_output_field_ordering() {
|
|||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines[0], "RQ,Brand,Model,Year,Count");
|
||||
assert_eq!(lines[1], "85,BMW,M3,2023,7");
|
||||
assert_eq!(lines[0], "RQ,Brand,Model,Year,FinalBuild,Count");
|
||||
assert_eq!(lines[1], "85,BMW,M3,2023,332,7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -193,13 +181,8 @@ fn test_track_translation_pipeline() {
|
|||
|
||||
#[test]
|
||||
fn test_condition_translation_pipeline() {
|
||||
let conditions = vec![
|
||||
("00", "Dry"),
|
||||
("01", "Wet"),
|
||||
("10", "Dirt"),
|
||||
("11", "Dirt Wet"),
|
||||
("60", "Snow"),
|
||||
];
|
||||
let conditions =
|
||||
vec![("00", "Dry"), ("01", "Wet"), ("10", "Dirt"), ("11", "Dirt Wet"), ("60", "Snow")];
|
||||
|
||||
for (code, expected) in conditions {
|
||||
assert_eq!(translate_track_type(code), expected);
|
||||
|
|
@ -222,31 +205,25 @@ fn test_ownership_filtering_in_counts() {
|
|||
let rec_unowned = make_record("Unowned_Car", 90, 95.0, "guid_unowned");
|
||||
|
||||
for i in 0..10 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 85, "Dry".to_string()),
|
||||
vec![rec_owned.clone()],
|
||||
);
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 90, "Dry".to_string()),
|
||||
vec![rec_unowned.clone()],
|
||||
);
|
||||
ctx.best_cars.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_owned.clone()]);
|
||||
ctx.best_cars
|
||||
.insert((format!("Track{i}"), 90, "Dry".to_string()), vec![rec_unowned.clone()]);
|
||||
}
|
||||
|
||||
let car_list = vec![
|
||||
make_car("Owned_Car", 85, "guid_owned"),
|
||||
make_car("Unowned_Car", 90, "guid_unowned"),
|
||||
];
|
||||
let car_list =
|
||||
vec![make_car("Owned_Car", 85, "guid_owned"), make_car("Unowned_Car", 90, "guid_unowned")];
|
||||
|
||||
let counts = compute_car_counts(&ctx, car_list);
|
||||
let counts = compute_car_counts(&ctx, car_list, empty_my_data());
|
||||
|
||||
// Both should be in by_rid
|
||||
assert_eq!(counts.by_rid.len(), 2);
|
||||
|
||||
// Verify ownership is reflected in the records
|
||||
let (owned_rec, _) = counts.by_rid.get("Owned_Car").unwrap();
|
||||
let (owned_rec, _) = counts.by_rid.get(&("Owned_Car".to_string(), "332".to_string())).unwrap();
|
||||
assert!(owned_rec.owned);
|
||||
|
||||
let (unowned_rec, _) = counts.by_rid.get("Unowned_Car").unwrap();
|
||||
let (unowned_rec, _) =
|
||||
counts.by_rid.get(&("Unowned_Car".to_string(), "332".to_string())).unwrap();
|
||||
assert!(!unowned_rec.owned);
|
||||
}
|
||||
|
||||
|
|
@ -261,9 +238,9 @@ fn test_load_my_cars_data_with_fixture() {
|
|||
|
||||
let json = r#"{
|
||||
"playerDeck": [
|
||||
{ "cardId": "guid_a", "locked": true },
|
||||
{ "cardId": "guid_b", "locked": false },
|
||||
{ "cardId": "guid_c", "locked": true }
|
||||
{ "cardId": "guid_a", "locked": true, "engineMajor": 3, "weightMajor": 2, "chassisMajor": 3 },
|
||||
{ "cardId": "guid_b", "locked": false, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 },
|
||||
{ "cardId": "guid_c", "locked": true, "engineMajor": 1, "weightMajor": 1, "chassisMajor": 1 }
|
||||
],
|
||||
"wishlists": [
|
||||
{ "cards": ["guid_d", "guid_e"] }
|
||||
|
|
@ -279,12 +256,45 @@ fn test_load_my_cars_data_with_fixture() {
|
|||
assert!(data.owned.contains("guid_c"));
|
||||
assert!(!data.owned.contains("guid_b"));
|
||||
|
||||
// owned_configs only for locked cards with complete tune data
|
||||
assert_eq!(data.owned_configs.len(), 2);
|
||||
assert_eq!(data.owned_configs.get("guid_a"), Some(&"323".to_string()));
|
||||
assert_eq!(data.owned_configs.get("guid_c"), Some(&"111".to_string()));
|
||||
// Unlocked card should not be in owned_configs
|
||||
assert!(!data.owned_configs.contains_key("guid_b"));
|
||||
|
||||
// Wishlist
|
||||
assert_eq!(data.wishlist.len(), 2);
|
||||
assert!(data.wishlist.contains("guid_d"));
|
||||
assert!(data.wishlist.contains("guid_e"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_my_cars_data_incomplete_tune_data() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("my_cars.json");
|
||||
|
||||
let json = r#"{
|
||||
"playerDeck": [
|
||||
{ "cardId": "guid_full", "locked": true, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 },
|
||||
{ "cardId": "guid_partial", "locked": true, "engineMajor": 2, "weightMajor": 3 },
|
||||
{ "cardId": "guid_empty", "locked": true }
|
||||
]
|
||||
}"#;
|
||||
fs::write(&path, json).unwrap();
|
||||
|
||||
let data = load_my_cars_data(path.to_str().unwrap()).unwrap();
|
||||
|
||||
// All locked cards are owned
|
||||
assert_eq!(data.owned.len(), 3);
|
||||
|
||||
// Only the card with complete tune data has a config entry
|
||||
assert_eq!(data.owned_configs.len(), 1);
|
||||
assert_eq!(data.owned_configs.get("guid_full"), Some(&"233".to_string()));
|
||||
assert!(!data.owned_configs.contains_key("guid_partial"));
|
||||
assert!(!data.owned_configs.contains_key("guid_empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_my_cars_data_empty_wishlist() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -2,19 +2,19 @@
|
|||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use top_drives::{compare_cars, compute_car_counts, AnalysisContext, Car};
|
||||
use top_drives::test_utils::{make_car, make_record};
|
||||
use top_drives::{compare_cars, compute_car_counts, empty_my_data, AnalysisContext, Car};
|
||||
|
||||
/// Generate arbitrary Car instances for property testing.
|
||||
fn car_strategy() -> impl Strategy<Value = Car> {
|
||||
(
|
||||
"[a-zA-Z]{2,10}", // rid
|
||||
"[a-zA-Z]{2,15}", // brand
|
||||
"[a-zA-Z0-9 ]{2,20}", // only_name
|
||||
1950u16..2030, // year
|
||||
10u8..113, // rq
|
||||
any::<bool>(), // prize
|
||||
"[a-zA-Z0-9_]{3,20}", // guid
|
||||
"[a-zA-Z]{2,10}", // rid
|
||||
"[a-zA-Z]{2,15}", // brand
|
||||
"[a-zA-Z0-9 ]{2,20}", // only_name
|
||||
1950u16..2030, // year
|
||||
10u8..113, // rq
|
||||
any::<bool>(), // prize
|
||||
"[a-zA-Z0-9_]{3,20}", // guid
|
||||
)
|
||||
.prop_map(|(rid, brand, only_name, year, rq, prize, guid)| Car {
|
||||
rid,
|
||||
|
|
@ -177,7 +177,7 @@ proptest! {
|
|||
cars in proptest::collection::vec(car_strategy(), 0..20)
|
||||
) {
|
||||
let ctx = AnalysisContext::default();
|
||||
let counts = compute_car_counts(&ctx, cars.clone());
|
||||
let counts = compute_car_counts(&ctx, cars.clone(), empty_my_data());
|
||||
// No best_cars entries → every car has 0 wins → by_rid is empty
|
||||
assert_eq!(counts.by_rid.len(), 0);
|
||||
// guid_to_rid is also empty since no car won any track
|
||||
|
|
@ -212,9 +212,9 @@ proptest! {
|
|||
}
|
||||
|
||||
let car_list = vec![make_car(&rid, rq, &guid)];
|
||||
let counts = compute_car_counts(&ctx, car_list);
|
||||
let counts = compute_car_counts(&ctx, car_list, empty_my_data());
|
||||
|
||||
let entry = counts.by_rid.get(&rid);
|
||||
let entry = counts.by_rid.get(&(rid.clone(), "332".to_string()));
|
||||
assert!(entry.is_some(), "car should appear in by_rid");
|
||||
let (_, count) = entry.unwrap();
|
||||
assert_eq!(*count, n_tracks, "count should match number of tracks");
|
||||
|
|
@ -240,9 +240,9 @@ proptest! {
|
|||
);
|
||||
|
||||
let car_list = vec![make_car(&rid, rq, &guid)];
|
||||
let counts = compute_car_counts(&ctx, car_list);
|
||||
let counts = compute_car_counts(&ctx, car_list, empty_my_data());
|
||||
|
||||
let (record, _) = counts.by_rid.get(&rid).unwrap();
|
||||
let (record, _) = counts.by_rid.get(&(rid, "332".to_string())).unwrap();
|
||||
assert_eq!(record.owned, owned, "owned flag should propagate");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue