test: use proptest for hitresults

This commit is contained in:
MaxOhn
2023-10-29 17:14:28 +01:00
parent 99b12376c3
commit 8527f2f391
3 changed files with 234 additions and 172 deletions
+7
View File
@@ -21,8 +21,15 @@ async-std = { version = "1.9", optional = true }
tokio = { version = "1.2", optional = true, default-features = false, features = ["fs", "io-util"] }
[dev-dependencies]
proptest = "1.3.1"
tokio = { version = "1.2", default-features = false, features = ["fs", "rt"] }
[profile.test.package.proptest]
opt-level = 3
[profile.test.package.rand_chacha]
opt-level = 3
[package.metadata.docs.rs]
# document these features
features = ["gradual"]
+13
View File
@@ -0,0 +1,13 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc aee5b74b9ef816122b8e9746e1666c6f1ee226bdf4fa6074dc561ead81e687d6 # shrinks to acc = 0.0, combo = None, n300 = None, n100 = None, n50 = None, n_misses = None, best_case = false
cc 65db688d970d7dd0c2e0921aa54f207810145b4d3db4c71a3332b7daf75c813f # shrinks to acc = 0.0, combo = None, n300 = None, n100 = None, n50 = None, n_misses = Some(1), best_case = false
cc 4dd36fb5fc6aaeb637305941c1dbd224df904729734425f6bd61aded44c20b82 # shrinks to acc = 0.7854494370626834, combo = None, n300 = None, n100 = Some(194), n50 = None, n_misses = None, best_case = false
cc 6df5e623c62ffa2830f17f4e7a9bb573cb3eb4c4c45bc3c0793aad26aa72cf95 # shrinks to acc = 0.0, combo = None, n300 = None, n100 = None, n50 = Some(0), n_misses = None, best_case = false
cc e5a861f6c665dd09e46423e71d7596edf98897d4130d3144aa6f5be580f31a8b # shrinks to acc = 0.0, combo = None, n300 = None, n100 = Some(293), n50 = None, n_misses = Some(309), best_case = false
cc 2cd5c105bcca0b4255afccc15bee3894b06bd20ac3f5c5d3b785f7e0ef99df46 # shrinks to acc = 0.0, combo = None, n300 = Some(0), n100 = None, n50 = Some(479), n_misses = Some(123), best_case = false
cc 2cba8a76243aac7233e9207a3162aaa1f08f933c0cb3a2ac79580ece3a7329fc # shrinks to acc = 0.0, n300 = Some(0), n100 = Some(0), n50 = Some(0), n_misses = None, best_case = false
+214 -172
View File
@@ -780,28 +780,220 @@ impl OsuAttributeProvider for PerformanceAttributes {
mod test {
use super::*;
use crate::Beatmap;
use proptest::{option, prelude::*};
use std::sync::OnceLock;
fn test_data() -> (Beatmap, OsuDifficultyAttributes) {
let path = "./maps/2785319.osu";
let map = Beatmap::from_path(path).unwrap();
static DATA: OnceLock<(Beatmap, OsuDifficultyAttributes)> = OnceLock::new();
let attrs = OsuDifficultyAttributes {
aim: 2.8693628443424104,
speed: 2.533869745015772,
flashlight: 2.288770487900865,
slider_factor: 0.9803052946037858,
speed_note_count: 210.36373973116545,
ar: 9.300000190734863,
od: 8.800000190734863,
hp: 5.0,
n_circles: 307,
n_sliders: 293,
n_spinners: 1,
stars: 5.669858729379631,
max_combo: 909,
const N_OBJECTS: usize = 601;
fn test_data() -> (&'static Beatmap, OsuDifficultyAttributes) {
let (map, attrs) = DATA.get_or_init(|| {
let path = "./maps/2785319.osu";
let map = Beatmap::from_path(path).unwrap();
let attrs = OsuDifficultyAttributes {
aim: 2.8693628443424104,
speed: 2.533869745015772,
flashlight: 2.288770487900865,
slider_factor: 0.9803052946037858,
speed_note_count: 210.36373973116545,
ar: 9.300000190734863,
od: 8.800000190734863,
hp: 5.0,
n_circles: 307,
n_sliders: 293,
n_spinners: 1,
stars: 5.669858729379631,
max_combo: 909,
};
assert_eq!(
N_OBJECTS,
attrs.n_circles + attrs.n_sliders + attrs.n_spinners
);
(map, attrs)
});
(map, attrs.to_owned())
}
/// Checks all remaining hitresult combinations w.r.t. the given parameters
/// and returns the [`OsuScoreState`] that matches `acc` the best.
///
/// Very slow but accurate.
fn brute_force_best(
acc: f64,
n300: Option<usize>,
n100: Option<usize>,
n50: Option<usize>,
n_misses: usize,
best_case: bool,
) -> OsuScoreState {
let n_misses = n_misses.min(N_OBJECTS);
let mut best_dist = f64::MAX;
let mut best_state = OsuScoreState {
n_misses,
..Default::default()
};
(map, attrs)
let mut bf_with_50 = |mut n300: usize, mut n100: usize, mut n50: usize, best_case: bool| {
let dist = (acc - accuracy(n300, n100, n50, n_misses)).abs();
let cond = (best_case && n50 < best_state.n50) || (!best_case && best_state.n50 < n50);
if dist < best_dist || ((dist - best_dist).abs() <= f64::EPSILON && cond) {
best_dist = dist;
if best_case {
let n = n300.min(n50 / 4);
n300 -= n;
n100 += 5 * n;
n50 -= 4 * n;
}
best_state.n300 = n300;
best_state.n100 = n100;
best_state.n50 = n50;
}
};
let mut bf_with_100 = |n300: usize, n100: usize, best_case: bool| match n50 {
Some(mut n50) => {
n50 = n50
.min(N_OBJECTS - n_misses)
.max(N_OBJECTS.saturating_sub(n300 + n100 + n_misses));
bf_with_50(n300, n100, n50, false);
}
None => {
let n50 = N_OBJECTS.saturating_sub(n300 + n100 + n_misses);
bf_with_50(n300, n100, n50, best_case);
}
};
let mut bf_with_300 = |n300: usize, best_case: bool| match (n100, n50) {
(Some(mut n100), _) => {
n100 = n100.min(N_OBJECTS - n_misses);
bf_with_100(n300, n100, false);
}
(None, Some(n50)) => {
let n100 = N_OBJECTS.saturating_sub(n300 + n50 + n_misses);
bf_with_100(n300, n100, best_case);
}
(None, None) => {
let n_remaining = N_OBJECTS.saturating_sub(n300 + n50.unwrap_or(0) + n_misses);
for n100 in 0..=n_remaining {
bf_with_100(n300, n100, best_case);
}
}
};
match (n300, n100, n50) {
(Some(mut n300), Some(n100), Some(n50)) => {
n300 = n300.min(N_OBJECTS - n_misses);
if best_case {
n300 = n300.max(N_OBJECTS.saturating_sub(n100 + n50 + n_misses));
}
bf_with_300(n300, false);
}
(Some(mut n300), ..) => {
n300 = n300.min(N_OBJECTS - n_misses);
bf_with_300(n300, false);
}
(None, Some(n100), Some(n50)) => {
let n300 = N_OBJECTS.saturating_sub(n100 + n50 + n_misses);
bf_with_300(n300, best_case);
}
(None, Some(n100), None) => {
let n_remaining = N_OBJECTS.saturating_sub(n100 + n_misses);
for n300 in 0..=n_remaining {
bf_with_300(n300, false);
}
}
(None, None, Some(n50)) => {
let n_remaining = N_OBJECTS.saturating_sub(n50 + n_misses);
for n300 in 0..=n_remaining {
bf_with_300(n300, false);
}
}
(None, None, None) => {
let n_remaining = N_OBJECTS - n_misses;
for n300 in 0..=n_remaining {
bf_with_300(n300, best_case);
}
}
}
best_state
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn osu_hitresults(
acc in 0.0..=1.0,
n300 in option::weighted(0.10, 0_usize..=N_OBJECTS),
n100 in option::weighted(0.10, 0_usize..=N_OBJECTS),
n50 in option::weighted(0.10, 0_usize..=N_OBJECTS),
n_misses in option::weighted(0.15, 0_usize..=N_OBJECTS),
best_case in prop::bool::ANY,
) {
prop_assume!((n300, n100, n50) != (Some(0), Some(0), Some(0)));
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let priority = if best_case {
HitResultPriority::BestCase
} else {
HitResultPriority::WorstCase
};
let mut state = OsuPP::new(map)
.attributes(attrs)
.accuracy(acc * 100.0)
.hitresult_priority(priority);
if let Some(n300) = n300 {
state = state.n300(n300);
}
if let Some(n100) = n100 {
state = state.n100(n100);
}
if let Some(n50) = n50 {
state = state.n50(n50);
}
if let Some(n_misses) = n_misses {
state = state.n_misses(n_misses);
}
let hitresults = state.generate_hitresults(max_combo);
let mut expected = brute_force_best(
acc,
n300,
n100,
n50,
n_misses.unwrap_or(0),
best_case,
);
expected.max_combo = max_combo.saturating_sub(n_misses.unwrap_or(0));
assert_eq!(hitresults, expected);
}
}
#[test]
@@ -809,7 +1001,7 @@ mod test {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let state = OsuPP::new(&map)
let state = OsuPP::new(map)
.attributes(attrs)
.combo(500)
.n300(300)
@@ -834,7 +1026,7 @@ mod test {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let state = OsuPP::new(&map)
let state = OsuPP::new(map)
.attributes(attrs)
.combo(500)
.n300(300)
@@ -859,7 +1051,7 @@ mod test {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let state = OsuPP::new(&map)
let state = OsuPP::new(map)
.attributes(attrs)
.combo(500)
.n50(10)
@@ -883,7 +1075,7 @@ mod test {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let state = OsuPP::new(&map)
let state = OsuPP::new(map)
.attributes(attrs)
.combo(500)
.n300(300)
@@ -903,154 +1095,4 @@ mod test {
assert_eq!(state, expected);
}
#[test]
fn hitresults_acc_n_misses_best() {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let state = OsuPP::new(&map)
.attributes(attrs)
.combo(500)
.accuracy(98.0)
.n_misses(2)
.hitresult_priority(HitResultPriority::BestCase)
.generate_hitresults(max_combo);
let expected = OsuScoreState {
max_combo: 500,
n300: 584,
n100: 15,
n50: 0,
n_misses: 2,
};
assert_eq!(
state,
expected,
"{}% vs {}%",
state.accuracy(),
expected.accuracy()
);
}
#[test]
fn hitresults_acc_n100_n_misses_best() {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let state = OsuPP::new(&map)
.attributes(attrs)
.combo(500)
.accuracy(95.0)
.n100(15)
.n_misses(2)
.hitresult_priority(HitResultPriority::BestCase)
.generate_hitresults(max_combo);
let expected = OsuScoreState {
max_combo: 500,
n300: 562,
n100: 15,
n50: 22,
n_misses: 2,
};
assert_eq!(
state,
expected,
"{}% vs {}%",
state.accuracy(),
expected.accuracy()
);
}
#[test]
fn hitresults_acc_n50_n_misses_best() {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let state = OsuPP::new(&map)
.attributes(attrs)
.combo(500)
.accuracy(95.0)
.n50(10)
.n_misses(2)
.hitresult_priority(HitResultPriority::BestCase)
.generate_hitresults(max_combo);
let expected = OsuScoreState {
max_combo: 500,
n300: 560,
n100: 29,
n50: 10,
n_misses: 2,
};
assert_eq!(
state,
expected,
"{}% vs {}%",
state.accuracy(),
expected.accuracy()
);
}
#[test]
fn hitresults_acc_best() {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let state = OsuPP::new(&map)
.attributes(attrs)
.combo(500)
.accuracy(90.0)
.hitresult_priority(HitResultPriority::BestCase)
.generate_hitresults(max_combo);
let expected = OsuScoreState {
max_combo: 500,
n300: 511,
n100: 89,
n50: 1,
n_misses: 0,
};
assert_eq!(
state,
expected,
"{}% vs {}%",
state.accuracy(),
expected.accuracy()
);
}
#[test]
fn hitresults_acc_worst() {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let state = OsuPP::new(&map)
.attributes(attrs)
.combo(500)
.accuracy(90.0)
.hitresult_priority(HitResultPriority::WorstCase)
.generate_hitresults(max_combo);
let expected = OsuScoreState {
max_combo: 500,
n300: 528,
n100: 4,
n50: 69,
n_misses: 0,
};
assert_eq!(
state,
expected,
"{}% vs {}%",
state.accuracy(),
expected.accuracy()
);
}
}