- 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
248 lines
8.4 KiB
Rust
248 lines
8.4 KiB
Rust
//! Property-based tests using proptest for sorting and comparison functions.
|
|
|
|
use proptest::prelude::*;
|
|
|
|
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
|
|
)
|
|
.prop_map(|(rid, brand, only_name, year, rq, prize, guid)| Car {
|
|
rid,
|
|
brand,
|
|
only_name,
|
|
year,
|
|
rq,
|
|
prize,
|
|
guid,
|
|
})
|
|
}
|
|
|
|
proptest! {
|
|
// =======================================================================
|
|
// compare_cars: antisymmetry property
|
|
// =======================================================================
|
|
|
|
#[test]
|
|
fn compare_cars_antisymmetric(
|
|
a in car_strategy(),
|
|
b in car_strategy(),
|
|
) {
|
|
// If compare_cars(a, b) == Less, then compare_cars(b, a) == Greater
|
|
// If compare_cars(a, b) == Equal, then compare_cars(b, a) == Equal
|
|
let ab = compare_cars(&a, &b);
|
|
let ba = compare_cars(&b, &a);
|
|
assert_eq!(ab.reverse(), ba);
|
|
}
|
|
|
|
// =======================================================================
|
|
// compare_cars: reflexivity (equal elements compare as Equal)
|
|
// =======================================================================
|
|
|
|
#[test]
|
|
fn compare_cars_reflexive(a in car_strategy()) {
|
|
assert_eq!(compare_cars(&a, &a), std::cmp::Ordering::Equal);
|
|
}
|
|
|
|
// =======================================================================
|
|
// compare_cars: transitivity
|
|
// =======================================================================
|
|
|
|
#[test]
|
|
fn compare_cars_transitive(
|
|
a in car_strategy(),
|
|
b in car_strategy(),
|
|
c in car_strategy(),
|
|
) {
|
|
let ab = compare_cars(&a, &b);
|
|
let bc = compare_cars(&b, &c);
|
|
let ac = compare_cars(&a, &c);
|
|
|
|
// If a <= b and b <= c, then a <= c
|
|
if ab != std::cmp::Ordering::Greater && bc != std::cmp::Ordering::Greater {
|
|
assert_ne!(ac, std::cmp::Ordering::Greater);
|
|
}
|
|
// If a >= b and b >= c, then a >= c
|
|
if ab != std::cmp::Ordering::Less && bc != std::cmp::Ordering::Less {
|
|
assert_ne!(ac, std::cmp::Ordering::Less);
|
|
}
|
|
}
|
|
|
|
// =======================================================================
|
|
// compare_cars: RQ is the primary sort key
|
|
// =======================================================================
|
|
|
|
#[test]
|
|
fn compare_cars_rq_primary(
|
|
brand_a in "[a-zA-Z]{2,10}",
|
|
brand_b in "[a-zA-Z]{2,10}",
|
|
model_a in "[a-zA-Z]{2,10}",
|
|
model_b in "[a-zA-Z]{2,10}",
|
|
rq_a in 10u8..112u8,
|
|
rq_offset in 1u8..=3u8,
|
|
) {
|
|
let rq_b = rq_a.saturating_add(rq_offset).min(113);
|
|
let a = Car {
|
|
rid: "A".to_string(),
|
|
brand: brand_a,
|
|
only_name: model_a,
|
|
year: 2020,
|
|
rq: rq_a,
|
|
prize: false,
|
|
guid: "guid_a".to_string(),
|
|
};
|
|
let b = Car {
|
|
rid: "B".to_string(),
|
|
brand: brand_b,
|
|
only_name: model_b,
|
|
year: 2021,
|
|
rq: rq_b,
|
|
prize: true,
|
|
guid: "guid_b".to_string(),
|
|
};
|
|
|
|
// Lower RQ always sorts first, regardless of other fields
|
|
assert_eq!(compare_cars(&a, &b), std::cmp::Ordering::Less);
|
|
}
|
|
|
|
// =======================================================================
|
|
// compare_cars: brand is the secondary sort key when RQ is equal
|
|
// =======================================================================
|
|
|
|
#[test]
|
|
fn compare_cars_brand_secondary(
|
|
brand_a in "[a-zA-Z]{2,10}",
|
|
model in "[a-zA-Z]{2,10}",
|
|
rq in 10u8..113u8,
|
|
) {
|
|
let mut brand_b = brand_a.clone();
|
|
brand_b.push('z'); // brand_b > brand_a lexicographically
|
|
|
|
let a = Car {
|
|
rid: "A".to_string(),
|
|
brand: brand_a,
|
|
only_name: model,
|
|
year: 2020,
|
|
rq,
|
|
prize: false,
|
|
guid: "guid_a".to_string(),
|
|
};
|
|
let b = Car {
|
|
rid: "B".to_string(),
|
|
brand: brand_b,
|
|
only_name: "Z".to_string(),
|
|
year: 2021,
|
|
rq,
|
|
prize: true,
|
|
guid: "guid_b".to_string(),
|
|
};
|
|
|
|
assert_eq!(compare_cars(&a, &b), std::cmp::Ordering::Less);
|
|
}
|
|
|
|
// =======================================================================
|
|
// escape_csv_field: no double-escaping
|
|
// =======================================================================
|
|
|
|
#[test]
|
|
fn escape_csv_no_double_escape(field in "[a-zA-Z0-9 ]{0,50}") {
|
|
let escaped = top_drives::escape_csv_field(&field);
|
|
// Simple fields should not be modified
|
|
assert_eq!(escaped, field);
|
|
}
|
|
|
|
#[test]
|
|
fn escape_csv_with_comma(field in "[a-zA-Z]{1,10}") {
|
|
let input = format!("{field},test");
|
|
let escaped = top_drives::escape_csv_field(&input);
|
|
assert!(escaped.starts_with('"'));
|
|
assert!(escaped.ends_with('"'));
|
|
}
|
|
|
|
// =======================================================================
|
|
// compute_car_counts: empty context always yields zero counts
|
|
// =======================================================================
|
|
|
|
#[test]
|
|
fn compute_car_counts_empty_ctx(
|
|
cars in proptest::collection::vec(car_strategy(), 0..20)
|
|
) {
|
|
let ctx = AnalysisContext::default();
|
|
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
|
|
assert_eq!(counts.guid_to_rid.len(), 0);
|
|
// But car_map contains ALL cars from the list
|
|
assert_eq!(counts.car_map.len(), cars.len());
|
|
for car in &cars {
|
|
assert!(counts.car_map.contains_key(&car.guid));
|
|
}
|
|
}
|
|
|
|
// =======================================================================
|
|
// compute_car_counts: car with wins has correct count
|
|
// =======================================================================
|
|
|
|
#[test]
|
|
fn compute_car_counts_reflects_wins(
|
|
n_tracks in 1usize..30,
|
|
rid in "[a-zA-Z]{3,8}",
|
|
guid in "[a-zA-Z0-9_]{3,10}",
|
|
rq in 10u8..113u8,
|
|
) {
|
|
let ctx = AnalysisContext::default();
|
|
let rec = make_record(&rid, rq, 100.0, &guid);
|
|
|
|
// Insert the car as best on n_tracks tracks
|
|
for i in 0..n_tracks {
|
|
ctx.best_cars.insert(
|
|
(format!("track_{i}"), rq, "Dry".to_string()),
|
|
vec![rec.clone()],
|
|
);
|
|
}
|
|
|
|
let car_list = vec![make_car(&rid, rq, &guid)];
|
|
let counts = compute_car_counts(&ctx, car_list, empty_my_data());
|
|
|
|
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");
|
|
}
|
|
|
|
// =======================================================================
|
|
// compute_car_counts: ownership flag propagates correctly
|
|
// =======================================================================
|
|
|
|
#[test]
|
|
fn compute_car_counts_ownership(
|
|
rid in "[a-zA-Z]{3,8}",
|
|
guid in "[a-zA-Z0-9_]{3,10}",
|
|
rq in 10u8..113u8,
|
|
owned in any::<bool>(),
|
|
) {
|
|
let ctx = AnalysisContext::default();
|
|
let mut rec = make_record(&rid, rq, 100.0, &guid);
|
|
rec.owned = owned;
|
|
ctx.best_cars.insert(
|
|
("track_x".to_string(), rq, "Dry".to_string()),
|
|
vec![rec],
|
|
);
|
|
|
|
let car_list = vec![make_car(&rid, rq, &guid)];
|
|
let counts = compute_car_counts(&ctx, car_list, empty_my_data());
|
|
|
|
let (record, _) = counts.by_rid.get(&(rid, "332".to_string())).unwrap();
|
|
assert_eq!(record.owned, owned, "owned flag should propagate");
|
|
}
|
|
}
|