test: use proptest for hitresults
This commit is contained in:
@@ -21,8 +21,15 @@ async-std = { version = "1.9", optional = true }
|
|||||||
tokio = { version = "1.2", optional = true, default-features = false, features = ["fs", "io-util"] }
|
tokio = { version = "1.2", optional = true, default-features = false, features = ["fs", "io-util"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
proptest = "1.3.1"
|
||||||
tokio = { version = "1.2", default-features = false, features = ["fs", "rt"] }
|
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]
|
[package.metadata.docs.rs]
|
||||||
# document these features
|
# document these features
|
||||||
features = ["gradual"]
|
features = ["gradual"]
|
||||||
|
|||||||
@@ -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
|
||||||
+197
-155
@@ -780,8 +780,15 @@ impl OsuAttributeProvider for PerformanceAttributes {
|
|||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::Beatmap;
|
use crate::Beatmap;
|
||||||
|
use proptest::{option, prelude::*};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
fn test_data() -> (Beatmap, OsuDifficultyAttributes) {
|
static DATA: OnceLock<(Beatmap, OsuDifficultyAttributes)> = OnceLock::new();
|
||||||
|
|
||||||
|
const N_OBJECTS: usize = 601;
|
||||||
|
|
||||||
|
fn test_data() -> (&'static Beatmap, OsuDifficultyAttributes) {
|
||||||
|
let (map, attrs) = DATA.get_or_init(|| {
|
||||||
let path = "./maps/2785319.osu";
|
let path = "./maps/2785319.osu";
|
||||||
let map = Beatmap::from_path(path).unwrap();
|
let map = Beatmap::from_path(path).unwrap();
|
||||||
|
|
||||||
@@ -801,7 +808,192 @@ mod test {
|
|||||||
max_combo: 909,
|
max_combo: 909,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
N_OBJECTS,
|
||||||
|
attrs.n_circles + attrs.n_sliders + attrs.n_spinners
|
||||||
|
);
|
||||||
|
|
||||||
(map, attrs)
|
(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()
|
||||||
|
};
|
||||||
|
|
||||||
|
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]
|
#[test]
|
||||||
@@ -809,7 +1001,7 @@ mod test {
|
|||||||
let (map, attrs) = test_data();
|
let (map, attrs) = test_data();
|
||||||
let max_combo = attrs.max_combo();
|
let max_combo = attrs.max_combo();
|
||||||
|
|
||||||
let state = OsuPP::new(&map)
|
let state = OsuPP::new(map)
|
||||||
.attributes(attrs)
|
.attributes(attrs)
|
||||||
.combo(500)
|
.combo(500)
|
||||||
.n300(300)
|
.n300(300)
|
||||||
@@ -834,7 +1026,7 @@ mod test {
|
|||||||
let (map, attrs) = test_data();
|
let (map, attrs) = test_data();
|
||||||
let max_combo = attrs.max_combo();
|
let max_combo = attrs.max_combo();
|
||||||
|
|
||||||
let state = OsuPP::new(&map)
|
let state = OsuPP::new(map)
|
||||||
.attributes(attrs)
|
.attributes(attrs)
|
||||||
.combo(500)
|
.combo(500)
|
||||||
.n300(300)
|
.n300(300)
|
||||||
@@ -859,7 +1051,7 @@ mod test {
|
|||||||
let (map, attrs) = test_data();
|
let (map, attrs) = test_data();
|
||||||
let max_combo = attrs.max_combo();
|
let max_combo = attrs.max_combo();
|
||||||
|
|
||||||
let state = OsuPP::new(&map)
|
let state = OsuPP::new(map)
|
||||||
.attributes(attrs)
|
.attributes(attrs)
|
||||||
.combo(500)
|
.combo(500)
|
||||||
.n50(10)
|
.n50(10)
|
||||||
@@ -883,7 +1075,7 @@ mod test {
|
|||||||
let (map, attrs) = test_data();
|
let (map, attrs) = test_data();
|
||||||
let max_combo = attrs.max_combo();
|
let max_combo = attrs.max_combo();
|
||||||
|
|
||||||
let state = OsuPP::new(&map)
|
let state = OsuPP::new(map)
|
||||||
.attributes(attrs)
|
.attributes(attrs)
|
||||||
.combo(500)
|
.combo(500)
|
||||||
.n300(300)
|
.n300(300)
|
||||||
@@ -903,154 +1095,4 @@ mod test {
|
|||||||
|
|
||||||
assert_eq!(state, expected);
|
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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user