Skip to content

Commit

Permalink
test: add property-based tests
Browse files Browse the repository at this point in the history
  • Loading branch information
loyd committed Apr 2, 2024
1 parent 77c393b commit 23d7a14
Show file tree
Hide file tree
Showing 3 changed files with 226 additions and 0 deletions.
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ fastrand = "2"
criterion = "0.5.1"
mimalloc = { version = "0.1.29", default_features = false }
trybuild = "1"
proptest = "1"
indexmap = "2"
sharded-slab = "0.1.7" # for benchmarking

[profile.release]
Expand Down
10 changes: 10 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ impl<T: 'static> Default for Idr<T> {
}

impl<T: 'static, C: Config> Idr<T, C> {
/// The number of bits in each key which are used by the IDR.
///
/// If other data is packed into the keys returned by [`Idr::insert()`],
/// user code is free to use any bits higher than the `USED_BITS`-th bit.
///
/// This is determined by the [`Config`] type that configures the IDR's
/// parameters. By default, all bits are used; this can be changed by
/// overriding the [`Config::RESERVED_BITS`] constant.
pub const USED_BITS: u32 = C::USED_BITS;

/// Returns a new IDR with the provided configuration parameters.
pub fn new() -> Self {
// Perform compile-time postmono checks.
Expand Down
214 changes: 214 additions & 0 deletions tests/properties.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
//! This module contains property-based tests against the public API:
//! * API never panics.
//! * Active entries cannot be overridden until removed.
//! * The IDR doesn't produce overlapping keys.
//! * The IDR doesn't leave "lost" keys.
//! * `get()`, `get_owned`, and `contains()` are consistent.
//! * `RESERVED_BITS` are actually not used.
//!
//! The test is supposed to be deterministic.
//! TODO: but it's not because of `insert()` randomization.
//!
//! We're not checking concurrency issues here, they should be covered by loom
//! tests anyway. Thus, it's fine to run all actions consequently.

use std::{num::NonZeroU64, ops::Range};

use indexmap::IndexMap;
use proptest::prelude::*;

use idr_ebr::{Config, DefaultConfig, Guard, Idr, Key};

const ACTIONS: Range<usize> = 1..1000;

#[derive(Debug, Clone)]
enum Action {
Insert,
VacantEntry,
RemoveRandom(Key),
RemoveExistent(/* seed */ usize),
GetRandom(Key),
GetExistent(/* seed */ usize),
}

fn action_strategy() -> impl Strategy<Value = Action> {
prop_oneof![
1 => Just(Action::Insert),
1 => Just(Action::VacantEntry),
1 => key_strategy().prop_map(Action::RemoveRandom),
1 => prop::num::usize::ANY.prop_map(Action::RemoveExistent),
// Produce `GetRandom` and `GetExistent` more often.
5 => key_strategy().prop_map(Action::GetRandom),
5 => prop::num::usize::ANY.prop_map(Action::GetExistent),
]
}

fn key_strategy() -> impl Strategy<Value = Key> {
(1u64..u64::MAX).prop_map(|v| NonZeroU64::new(v).unwrap().into())
}

/// Stores active entries (added and not yet removed).
#[derive(Default)]
struct Active {
// Use `IndexMap` to preserve determinism.
map: IndexMap<Key, u32>,
prev_value: u32,
}

impl Active {
fn next_value(&mut self) -> u32 {
self.prev_value += 1;
self.prev_value
}

fn get(&self, key: Key) -> Option<u32> {
self.map.get(&key).copied()
}

fn get_any(&self, seed: usize) -> Option<(Key, u32)> {
if self.map.is_empty() {
return None;
}

let index = seed % self.map.len();
self.map.get_index(index).map(|(k, v)| (*k, *v))
}

fn insert(&mut self, key: Key, value: u32) {
assert_eq!(
self.map.insert(key, value),
None,
"keys of active entries must be unique"
);
}

fn remove(&mut self, key: Key) -> Option<u32> {
self.map.swap_remove(&key)
}

fn remove_any(&mut self, seed: usize) -> Option<(Key, u32)> {
if self.map.is_empty() {
return None;
}

let index = seed % self.map.len();
self.map.swap_remove_index(index)
}

fn drain(&mut self) -> impl Iterator<Item = (Key, u32)> + '_ {
self.map.drain(..)
}
}

fn used_bits<C: Config>(key: Key) -> Key {
assert_eq!(C::RESERVED_BITS + Idr::<u32, C>::USED_BITS, 64);

let raw_key = NonZeroU64::from(key).get();
let refined = raw_key & ((!0) >> C::RESERVED_BITS);
NonZeroU64::new(refined).unwrap().into()
}

#[allow(clippy::needless_pass_by_value)]
fn apply_action<C: Config>(
idr: &Idr<u32, C>,
active: &mut Active,
action: Action,
) -> Result<(), TestCaseError> {
match action {
Action::Insert => {
let value = active.next_value();
let key = idr.insert(value).expect("unexpectedly exhausted idr");
prop_assert_eq!(used_bits::<C>(key), key);
active.insert(key, value);
}
Action::VacantEntry => {
let value = active.next_value();
let entry = idr.vacant_entry().expect("unexpectedly exhausted idr");
let key = entry.key();
prop_assert_eq!(used_bits::<C>(key), key);
entry.insert(value);
active.insert(key, value);
}
Action::RemoveRandom(key) => {
let used_key = used_bits::<C>(key);
prop_assert_eq!(
idr.get(key, &Guard::new()).map(|e| *e),
idr.get(used_key, &Guard::new()).map(|e| *e)
);
prop_assert_eq!(idr.remove(key), active.remove(used_key).is_some());
}
Action::RemoveExistent(seed) => {
if let Some((key, _value)) = active.remove_any(seed) {
prop_assert!(idr.contains(key));
prop_assert!(idr.remove(key));
}
}
Action::GetRandom(key) => {
let used_key = used_bits::<C>(key);
prop_assert_eq!(
idr.get(key, &Guard::new()).map(|e| *e),
idr.get(used_key, &Guard::new()).map(|e| *e)
);
prop_assert_eq!(
idr.get(key, &Guard::new()).map(|e| *e),
active.get(used_key)
);
prop_assert_eq!(idr.get_owned(key).map(|e| *e), active.get(used_key));
}
Action::GetExistent(seed) => {
if let Some((key, value)) = active.get_any(seed) {
prop_assert!(idr.contains(key));
prop_assert_eq!(idr.get(key, &Guard::new()).map(|e| *e), Some(value));
prop_assert_eq!(idr.get_owned(key).map(|e| *e), Some(value));
}
}
}

Ok(())
}

fn run<C: Config>(actions: Vec<Action>) -> Result<(), TestCaseError> {
let idr = Idr::<u32, C>::new();
let mut active = Active::default();

// Apply all actions.
for action in actions {
apply_action::<C>(&idr, &mut active, action)?;
}

// Ensure the IDR contains all remaining entries.
let mut expected_values = Vec::new();
for (key, value) in active.drain() {
prop_assert!(idr.contains(key));
prop_assert_eq!(idr.get(key, &Guard::new()).map(|e| *e), Some(value));
prop_assert_eq!(idr.get_owned(key).map(|e| *e), Some(value));
expected_values.push(value);
}
expected_values.sort_unstable();

// Ensure `unique_iter()` returns all remaining entries.
let mut actual_values = idr.iter(&Guard::new()).map(|(_, v)| *v).collect::<Vec<_>>();
actual_values.sort_unstable();
prop_assert_eq!(actual_values, expected_values);

Ok(())
}

proptest! {
#[test]
fn default_config(actions in prop::collection::vec(action_strategy(), ACTIONS)) {
run::<DefaultConfig>(actions)?;
}

#[test]
fn custom_config(actions in prop::collection::vec(action_strategy(), ACTIONS)) {
run::<CustomConfig>(actions)?;
}
}

struct CustomConfig;
impl Config for CustomConfig {
const INITIAL_PAGE_SIZE: u32 = 32;
const MAX_PAGES: u32 = 20;
const RESERVED_BITS: u32 = 24;
}

0 comments on commit 23d7a14

Please sign in to comment.