use chrono::Utc;
use serde::{Deserialize, Serialize, Serializer};
#[cfg(feature = "sqlx")]
use sqlx::FromRow;
const SECONDS_PER_DAY: u64 = 86_400;
pub fn day_truncate(timestamp: i64) -> u64 {
truncate_since(timestamp.max(0) as u64)
}
pub(crate) fn truncate_since(since: u64) -> u64 {
since - since % SECONDS_PER_DAY
}
pub(crate) fn serialize_since<S: Serializer>(
since: &Option<u64>,
serializer: S,
) -> Result<S::Ok, S::Error> {
since.map(truncate_since).serialize(serializer)
}
#[derive(Debug, Default, Deserialize, Serialize, Clone)]
pub struct UserInfo {
pub rating: f64,
pub reviews: i64,
pub operating_days: u64,
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "serialize_since"
)]
pub since: Option<u64>,
}
#[cfg_attr(feature = "sqlx", derive(FromRow))]
#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
pub struct User {
pub pubkey: String,
pub is_admin: i64,
pub admin_password: Option<String>,
pub is_solver: i64,
pub is_banned: i64,
pub category: i64,
pub last_trade_index: i64,
pub total_reviews: i64,
pub total_rating: f64,
pub last_rating: i64,
pub max_rating: i64,
pub min_rating: i64,
pub created_at: i64,
}
impl User {
pub fn new(
pubkey: String,
is_admin: i64,
is_solver: i64,
is_banned: i64,
category: i64,
trade_index: i64,
) -> Self {
Self {
pubkey,
is_admin,
admin_password: None,
is_solver,
is_banned,
category,
last_trade_index: trade_index,
total_reviews: 0,
total_rating: 0.0,
last_rating: 0,
max_rating: 0,
min_rating: 0,
created_at: Utc::now().timestamp(),
}
}
pub fn update_rating(&mut self, rating: u8) {
self.total_reviews += 1;
let old_rating = self.total_rating;
if self.total_reviews <= 1 {
let first_rating = rating as f64;
self.total_rating = first_rating / 2.0;
self.max_rating = rating.into();
self.min_rating = rating.into();
} else {
self.total_rating =
old_rating + ((rating as f64) - old_rating) / (self.total_reviews as f64);
if self.max_rating < rating.into() {
self.max_rating = rating.into();
}
if self.min_rating > rating.into() {
self.min_rating = rating.into();
}
}
self.last_rating = rating.into();
}
}
#[cfg(test)]
mod tests {
use super::*;
const DAY_START: i64 = 1_767_225_600;
const LATE_IN_DAY: i64 = DAY_START + 86_399;
#[test]
fn day_truncate_lands_on_the_start_of_the_utc_day() {
assert_eq!(day_truncate(DAY_START), DAY_START as u64);
assert_eq!(day_truncate(LATE_IN_DAY), DAY_START as u64);
assert_eq!(
day_truncate(DAY_START + 86_400),
(DAY_START + 86_400) as u64
);
}
#[test]
fn day_truncate_clamps_a_pre_epoch_timestamp_instead_of_wrapping() {
assert_eq!(day_truncate(-1), 0);
assert_eq!(day_truncate(0), 0);
}
#[test]
fn user_info_omits_since_when_unset() {
let info = UserInfo {
rating: 4.5,
reviews: 10,
operating_days: 30,
since: None,
};
let json = serde_json::to_string(&info).expect("serializes");
assert!(!json.contains("since"), "{json}");
}
#[test]
fn user_info_round_trips_with_since() {
let info = UserInfo {
rating: 4.5,
reviews: 10,
operating_days: 30,
since: Some(DAY_START as u64),
};
let parsed: UserInfo =
serde_json::from_str(&serde_json::to_string(&info).expect("serializes"))
.expect("parses");
assert_eq!(parsed.since, Some(DAY_START as u64));
assert_eq!(parsed.operating_days, 30);
}
#[test]
fn user_info_truncates_since_on_serialization() {
let info = UserInfo {
rating: 4.5,
reviews: 10,
operating_days: 30,
since: Some(LATE_IN_DAY as u64),
};
let json = serde_json::to_string(&info).expect("serializes");
assert!(json.contains(&format!("\"since\":{DAY_START}")), "{json}");
}
#[test]
fn user_info_from_a_peer_without_since_parses() {
let json = r#"{"rating":4.5,"reviews":10,"operating_days":30}"#;
let parsed: UserInfo = serde_json::from_str(json).expect("parses");
assert_eq!(parsed.since, None);
assert_eq!(parsed.operating_days, 30);
}
#[test]
fn first_vote_is_weighted_by_half() {
let mut user = User::default();
user.update_rating(5);
assert_eq!(user.total_reviews, 1);
assert_eq!(user.total_rating, 2.5);
assert_eq!(user.last_rating, 5);
assert_eq!(user.max_rating, 5);
assert_eq!(user.min_rating, 5);
}
#[test]
fn second_vote_is_folded_into_the_average() {
let mut user = User::default();
user.update_rating(5);
user.update_rating(1);
assert_eq!(user.total_reviews, 2);
assert!((user.total_rating - 1.75).abs() < 1e-9);
assert_eq!(user.last_rating, 1);
}
#[test]
fn low_vote_lowers_a_high_average() {
let mut user = User::default();
for _ in 0..10 {
user.update_rating(5);
}
let farmed_average = user.total_rating;
assert!((farmed_average - 4.75).abs() < 1e-9);
user.update_rating(1);
let expected = 48.5 / 11.0;
assert!(
user.total_rating < farmed_average,
"a 1-star review must lower the average, got {} (was {})",
user.total_rating,
farmed_average
);
assert!((user.total_rating - expected).abs() < 1e-9);
assert_eq!(user.last_rating, 1);
assert_eq!(user.min_rating, 1);
assert_eq!(user.max_rating, 5);
}
#[test]
fn high_vote_raises_a_low_average() {
let mut user = User::default();
user.update_rating(1);
user.update_rating(1);
let low_average = user.total_rating;
user.update_rating(5);
assert!(
user.total_rating > low_average,
"a 5-star review must raise the average, got {} (was {})",
user.total_rating,
low_average
);
assert!((user.total_rating - 6.5 / 3.0).abs() < 1e-9);
}
#[test]
fn min_and_max_track_extremes() {
let mut user = User::default();
user.update_rating(3);
user.update_rating(5);
user.update_rating(1);
assert_eq!(user.max_rating, 5);
assert_eq!(user.min_rating, 1);
}
}