//! Property-based tests using proptest for sorting and comparison functions. use proptest::prelude::*; use top_drives::{compare_cars, compute_car_counts, AnalysisContext, Car}; use top_drives::test_utils::{make_car, make_record}; /// Generate arbitrary Car instances for property testing. fn car_strategy() -> impl Strategy { ( "[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::(), // 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()); // 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); let entry = counts.by_rid.get(&rid); 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::(), ) { 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); let (record, _) = counts.by_rid.get(&rid).unwrap(); assert_eq!(record.owned, owned, "owned flag should propagate"); } }