use std::{
collections::{BTreeSet, HashMap, HashSet},
fmt,
time::{Duration, Instant},
};
use rs_poker::arena::historian::StatsStorage;
use rs_poker::open_hand_history::{Action, HandHistory};
use crate::tui::event::SimError;
use crate::tui::widgets::stats_table::SortColumn;
pub const PROFIT_EPSILON: f32 = 0.01;
pub fn compute_hand_profits(hand: &HandHistory) -> (HashMap<u64, usize>, Vec<f32>) {
let num_players = hand.players.len();
let id_to_idx: HashMap<u64, usize> = hand
.players
.iter()
.enumerate()
.map(|(i, p)| (p.id, i))
.collect();
let mut wins = vec![0.0_f32; num_players];
for pot in &hand.pots {
for pw in &pot.player_wins {
if let Some(&idx) = id_to_idx.get(&pw.player_id) {
wins[idx] += pw.win_amount;
}
}
}
let mut invested = vec![0.0_f32; num_players];
for round in &hand.rounds {
for action in &round.actions {
if let Some(&idx) = id_to_idx.get(&action.player_id)
&& matches!(
action.action,
Action::Bet
| Action::Raise
| Action::Call
| Action::PostSmallBlind
| Action::PostBigBlind
| Action::PostAnte
| Action::Straddle
| Action::PostDead
| Action::PostExtraBlind
| Action::AddedToPot
)
{
invested[idx] += action.amount;
}
}
}
let mut profits = vec![0.0_f32; num_players];
for i in 0..num_players {
profits[i] = wins[i] - invested[i];
}
(id_to_idx, profits)
}
pub fn ending_round_from_stats(stats: &StatsStorage, num_players: usize) -> RoundLabel {
let any = |counts: &[usize]| counts.iter().take(num_players).any(|&c| c > 0);
if any(&stats.showdown_count) {
return RoundLabel::Showdown;
}
if any(&stats.river_completes) {
return RoundLabel::River;
}
if any(&stats.turn_completes) {
return RoundLabel::Turn;
}
if any(&stats.flop_completes) {
return RoundLabel::Flop;
}
RoundLabel::Preflop
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SeatStats {
pub actions_count: usize,
pub vpip_count: usize,
pub vpip_total: f32,
pub raise_count: usize,
pub hands_played: usize,
pub hands_vpip: usize,
pub hands_pfr: usize,
pub preflop_raise_count: usize,
pub preflop_actions: usize,
pub three_bet_count: usize,
pub three_bet_opportunities: usize,
pub call_count: usize,
pub bet_count: usize,
pub total_profit: f32,
pub total_invested: f32,
pub games_won: usize,
pub games_lost: usize,
pub games_breakeven: usize,
pub preflop_wins: usize,
pub flop_wins: usize,
pub turn_wins: usize,
pub river_wins: usize,
pub preflop_completes: usize,
pub flop_completes: usize,
pub turn_completes: usize,
pub river_completes: usize,
pub cbet_opportunities: usize,
pub cbet_count: usize,
pub wtsd_opportunities: usize,
pub wtsd_count: usize,
pub showdown_count: usize,
pub showdown_wins: usize,
pub fold_count: usize,
pub flop_bets: usize,
pub flop_raises: usize,
pub flop_calls: usize,
pub turn_bets: usize,
pub turn_raises: usize,
pub turn_calls: usize,
pub river_bets: usize,
pub river_raises: usize,
pub river_calls: usize,
pub steal_opportunities: usize,
pub steal_count: usize,
}
impl SeatStats {
pub fn from_storage(storage: &StatsStorage, seat: usize) -> Self {
Self {
actions_count: storage.actions_count[seat],
vpip_count: storage.vpip_count[seat],
vpip_total: storage.vpip_total[seat],
raise_count: storage.raise_count[seat],
hands_played: storage.hands_played[seat],
hands_vpip: storage.hands_vpip[seat],
hands_pfr: storage.hands_pfr[seat],
preflop_raise_count: storage.preflop_raise_count[seat],
preflop_actions: storage.preflop_actions[seat],
three_bet_count: storage.three_bet_count[seat],
three_bet_opportunities: storage.three_bet_opportunities[seat],
call_count: storage.call_count[seat],
bet_count: storage.bet_count[seat],
total_profit: storage.total_profit[seat],
total_invested: storage.total_invested[seat],
games_won: storage.games_won[seat],
games_lost: storage.games_lost[seat],
games_breakeven: storage.games_breakeven[seat],
preflop_wins: storage.preflop_wins[seat],
flop_wins: storage.flop_wins[seat],
turn_wins: storage.turn_wins[seat],
river_wins: storage.river_wins[seat],
preflop_completes: storage.preflop_completes[seat],
flop_completes: storage.flop_completes[seat],
turn_completes: storage.turn_completes[seat],
river_completes: storage.river_completes[seat],
cbet_opportunities: storage.cbet_opportunities[seat],
cbet_count: storage.cbet_count[seat],
wtsd_opportunities: storage.wtsd_opportunities[seat],
wtsd_count: storage.wtsd_count[seat],
showdown_count: storage.showdown_count[seat],
showdown_wins: storage.showdown_wins[seat],
fold_count: storage.fold_count[seat],
flop_bets: storage.flop_bets[seat],
flop_raises: storage.flop_raises[seat],
flop_calls: storage.flop_calls[seat],
turn_bets: storage.turn_bets[seat],
turn_raises: storage.turn_raises[seat],
turn_calls: storage.turn_calls[seat],
river_bets: storage.river_bets[seat],
river_raises: storage.river_raises[seat],
river_calls: storage.river_calls[seat],
steal_opportunities: storage.steal_opportunities[seat],
steal_count: storage.steal_count[seat],
}
}
pub fn merge_into(&self, dest: &mut StatsStorage) {
let d = 0;
dest.actions_count[d] += self.actions_count;
dest.vpip_count[d] += self.vpip_count;
dest.vpip_total[d] += self.vpip_total;
dest.raise_count[d] += self.raise_count;
dest.hands_played[d] += self.hands_played;
dest.hands_vpip[d] += self.hands_vpip;
dest.hands_pfr[d] += self.hands_pfr;
dest.preflop_raise_count[d] += self.preflop_raise_count;
dest.preflop_actions[d] += self.preflop_actions;
dest.three_bet_count[d] += self.three_bet_count;
dest.three_bet_opportunities[d] += self.three_bet_opportunities;
dest.call_count[d] += self.call_count;
dest.bet_count[d] += self.bet_count;
dest.total_profit[d] += self.total_profit;
dest.total_invested[d] += self.total_invested;
dest.games_won[d] += self.games_won;
dest.games_lost[d] += self.games_lost;
dest.games_breakeven[d] += self.games_breakeven;
dest.preflop_wins[d] += self.preflop_wins;
dest.flop_wins[d] += self.flop_wins;
dest.turn_wins[d] += self.turn_wins;
dest.river_wins[d] += self.river_wins;
dest.preflop_completes[d] += self.preflop_completes;
dest.flop_completes[d] += self.flop_completes;
dest.turn_completes[d] += self.turn_completes;
dest.river_completes[d] += self.river_completes;
dest.cbet_opportunities[d] += self.cbet_opportunities;
dest.cbet_count[d] += self.cbet_count;
dest.wtsd_opportunities[d] += self.wtsd_opportunities;
dest.wtsd_count[d] += self.wtsd_count;
dest.showdown_count[d] += self.showdown_count;
dest.showdown_wins[d] += self.showdown_wins;
dest.fold_count[d] += self.fold_count;
dest.flop_bets[d] += self.flop_bets;
dest.flop_raises[d] += self.flop_raises;
dest.flop_calls[d] += self.flop_calls;
dest.turn_bets[d] += self.turn_bets;
dest.turn_raises[d] += self.turn_raises;
dest.turn_calls[d] += self.turn_calls;
dest.river_bets[d] += self.river_bets;
dest.river_raises[d] += self.river_raises;
dest.river_calls[d] += self.river_calls;
dest.steal_opportunities[d] += self.steal_opportunities;
dest.steal_count[d] += self.steal_count;
}
}
#[derive(Debug, Clone)]
pub struct GameResult {
pub agent_names: Vec<String>,
pub profits: Vec<f32>,
pub ending_round: RoundLabel,
pub seat_stats: Vec<SeatStats>,
pub big_blind: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RoundLabel {
Preflop,
Flop,
Turn,
River,
Showdown,
}
impl RoundLabel {
pub fn from_street_name(s: &str) -> Self {
match s.to_lowercase().as_str() {
"preflop" => Self::Preflop,
"flop" => Self::Flop,
"turn" => Self::Turn,
"river" => Self::River,
"showdown" => Self::Showdown,
_ => Self::Showdown,
}
}
}
impl fmt::Display for RoundLabel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Preflop => write!(f, "preflop"),
Self::Flop => write!(f, "flop"),
Self::Turn => write!(f, "turn"),
Self::River => write!(f, "river"),
Self::Showdown => write!(f, "showdown"),
}
}
}
#[derive(Debug, Clone)]
pub struct AgentDisplayData {
pub name: String,
pub total_profit: f32,
pub profit_bb: f32,
pub games_played: usize,
pub wins: usize,
pub vpip_percent: f32,
pub pfr_percent: f32,
pub three_bet_percent: f32,
pub aggression_factor: f32,
pub cbet_percent: f32,
pub wtsd_percent: f32,
pub wsd_percent: f32,
pub roi_percent: f32,
}
#[derive(Debug, Clone, Default)]
pub struct StreetDistribution {
pub preflop: usize,
pub flop: usize,
pub turn: usize,
pub river: usize,
pub showdown: usize,
}
impl StreetDistribution {
pub fn total(&self) -> usize {
self.preflop + self.flop + self.turn + self.river + self.showdown
}
pub(crate) fn record(&mut self, round: RoundLabel) {
match round {
RoundLabel::Preflop => self.preflop += 1,
RoundLabel::Flop => self.flop += 1,
RoundLabel::Turn => self.turn += 1,
RoundLabel::River => self.river += 1,
RoundLabel::Showdown => self.showdown += 1,
}
}
}
fn toggle_in<T: Eq + std::hash::Hash>(set: &mut HashSet<T>, item: T) {
if !set.remove(&item) {
set.insert(item);
}
}
fn toggle_in_str(set: &mut HashSet<String>, name: &str) {
if !set.remove(name) {
set.insert(name.to_string());
}
}
#[derive(Debug, Clone, Default)]
pub struct FilterState {
pub winners: HashSet<String>,
pub losers: HashSet<String>,
pub participants: HashSet<String>,
pub streets: HashSet<RoundLabel>,
pub win_sizes: HashSet<ProfitBucket>,
pub loss_sizes: HashSet<ProfitBucket>,
pub player_counts: HashSet<usize>,
pub selected: usize,
}
impl FilterState {
pub fn is_active(&self) -> bool {
!self.winners.is_empty()
|| !self.losers.is_empty()
|| !self.participants.is_empty()
|| !self.streets.is_empty()
|| !self.win_sizes.is_empty()
|| !self.loss_sizes.is_empty()
|| !self.player_counts.is_empty()
}
pub fn clear(&mut self) {
self.winners.clear();
self.losers.clear();
self.participants.clear();
self.streets.clear();
self.win_sizes.clear();
self.loss_sizes.clear();
self.player_counts.clear();
}
pub fn toggle_winner(&mut self, name: &str) {
toggle_in_str(&mut self.winners, name);
}
pub fn toggle_loser(&mut self, name: &str) {
toggle_in_str(&mut self.losers, name);
}
pub fn toggle_participant(&mut self, name: &str) {
toggle_in_str(&mut self.participants, name);
}
pub fn toggle_street(&mut self, street: RoundLabel) {
toggle_in(&mut self.streets, street);
}
pub fn toggle_win_size(&mut self, bucket: ProfitBucket) {
toggle_in(&mut self.win_sizes, bucket);
}
pub fn toggle_loss_size(&mut self, bucket: ProfitBucket) {
toggle_in(&mut self.loss_sizes, bucket);
}
pub fn toggle_player_count(&mut self, count: usize) {
toggle_in(&mut self.player_counts, count);
}
pub fn matches_entry(&self, entry: &GameLogEntry) -> bool {
if !self.winners.is_empty() && !self.winners.contains(&entry.winner_name) {
return false;
}
if !self.losers.is_empty() && !self.losers.contains(&entry.loser_name) {
return false;
}
if !self.participants.is_empty() {
let has_participant = entry
.agent_names
.iter()
.any(|name| self.participants.contains(name));
if !has_participant {
return false;
}
}
if !self.streets.is_empty() && !self.streets.contains(&entry.ending_round) {
return false;
}
if !self.win_sizes.is_empty() {
let bucket = ProfitBucket::from_bb(entry.winner_profit);
if !self.win_sizes.contains(&bucket) {
return false;
}
}
if !self.loss_sizes.is_empty() {
let bucket = ProfitBucket::from_bb(entry.loser_loss.abs());
if !self.loss_sizes.contains(&bucket) {
return false;
}
}
if !self.player_counts.is_empty() && !self.player_counts.contains(&entry.agent_names.len())
{
return false;
}
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProfitBucket {
Small,
Medium,
Large,
Huge,
}
impl ProfitBucket {
pub fn from_bb(amount_bb: f32) -> Self {
if amount_bb < 5.0 {
Self::Small
} else if amount_bb < 20.0 {
Self::Medium
} else if amount_bb < 100.0 {
Self::Large
} else {
Self::Huge
}
}
pub fn label(self) -> &'static str {
match self {
Self::Small => "0-5bb",
Self::Medium => "5-20bb",
Self::Large => "20-100bb",
Self::Huge => "100+bb",
}
}
}
impl fmt::Display for ProfitBucket {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.label())
}
}
pub const ALL_PROFIT_BUCKETS: [ProfitBucket; 4] = [
ProfitBucket::Small,
ProfitBucket::Medium,
ProfitBucket::Large,
ProfitBucket::Huge,
];
#[derive(Debug, Clone)]
pub struct GameLogEntry {
pub game_number: usize,
pub agent_names: Vec<String>,
pub ending_round: RoundLabel,
pub winner_name: String,
pub winner_profit: f32,
pub loser_name: String,
pub loser_loss: f32,
pub pot_size: f32,
}
impl GameLogEntry {
pub fn new(
game_number: usize,
agent_names: Vec<String>,
profits: Vec<f32>,
ending_round: RoundLabel,
big_blind: f32,
) -> Self {
let to_bb = |chips: f32| {
if big_blind > 0.0 {
chips / big_blind
} else {
0.0
}
};
let (winner_name, winner_profit) = agent_names
.iter()
.zip(profits.iter())
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(n, &p)| (n.clone(), to_bb(p)))
.unwrap_or_default();
let (loser_name, loser_loss) = agent_names
.iter()
.zip(profits.iter())
.min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(n, &p)| (n.clone(), to_bb(p)))
.unwrap_or_default();
let pot_size: f32 = to_bb(profits.iter().filter(|&&p| p > 0.0).sum());
Self {
game_number,
agent_names,
ending_round,
winner_name,
winner_profit,
loser_name,
loser_loss,
pot_size,
}
}
pub fn from_hand(game_number: usize, hand: &HandHistory) -> Self {
let agent_names: Vec<String> = hand.players.iter().map(|p| p.name.clone()).collect();
let (_id_to_idx, profits) = compute_hand_profits(hand);
let ending_round = hand
.rounds
.last()
.map(|r| RoundLabel::from_street_name(&r.street))
.unwrap_or(RoundLabel::Preflop);
Self::new(
game_number,
agent_names,
profits,
ending_round,
hand.big_blind_amount,
)
}
}
#[derive(Debug, Default, Clone)]
pub struct ProfitHistory {
pub first_game_index: usize,
pub values: Vec<f32>,
}
impl ProfitHistory {
pub fn x_at(&self, i: usize) -> usize {
self.first_game_index + i
}
}
pub struct TuiState {
pub games_target: Option<usize>,
pub start_time: Instant,
pub completed: bool,
pub live: bool,
pub error: Option<SimError>,
base: crate::tui::projection::Projection,
filtered: Option<crate::tui::projection::Projection>,
pub distinct_player_counts: BTreeSet<usize>,
cached_agent_names: Option<Vec<String>>,
pub table_selected: Option<usize>,
pub log_selected: Option<usize>,
pub log_scroll: usize,
pub sort_col: SortColumn,
pub active_panel: Panel,
pub filter: FilterState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Panel {
Table,
GameLog,
Filter,
}
impl Panel {
pub fn next(self) -> Self {
match self {
Self::Table => Self::GameLog,
Self::GameLog => Self::Filter,
Self::Filter => Self::Table,
}
}
pub fn prev(self) -> Self {
match self {
Self::Table => Self::Filter,
Self::GameLog => Self::Table,
Self::Filter => Self::GameLog,
}
}
}
impl TuiState {
pub fn new(games_target: Option<usize>) -> Self {
Self {
games_target,
start_time: Instant::now(),
completed: false,
live: true,
error: None,
base: crate::tui::projection::Projection::new(),
filtered: None,
distinct_player_counts: BTreeSet::new(),
cached_agent_names: None,
table_selected: None,
log_selected: None,
log_scroll: 0,
sort_col: SortColumn::Profit,
active_panel: Panel::Table,
filter: FilterState::default(),
}
}
pub fn update(&mut self, result: &GameResult) {
self.base.fold(result);
self.distinct_player_counts.insert(result.agent_names.len());
self.cached_agent_names = None;
}
pub fn games_completed(&self) -> usize {
self.base.game_count()
}
pub fn matching_games(&self) -> usize {
self.filtered
.as_ref()
.map(|f| f.game_count())
.unwrap_or_else(|| self.base.game_count())
}
fn active_projection(&self) -> &crate::tui::projection::Projection {
self.filtered.as_ref().unwrap_or(&self.base)
}
fn active_projection_mut(&mut self) -> &mut crate::tui::projection::Projection {
self.filtered.as_mut().unwrap_or(&mut self.base)
}
pub fn set_filter_projection(&mut self, proj: Option<crate::tui::projection::Projection>) {
self.filtered = proj;
}
pub fn fold_filtered(&mut self, result: &GameResult) {
if let Some(f) = self.filtered.as_mut() {
f.fold(result);
}
}
pub fn agent_display_data(&mut self) -> Vec<AgentDisplayData> {
let sort = self.sort_col;
self.active_projection_mut().agent_display_data(sort)
}
pub fn all_agent_names(&mut self) -> Vec<String> {
if let Some(cached) = &self.cached_agent_names {
return cached.clone();
}
let names = self.base.agent_names();
self.cached_agent_names = Some(names.clone());
names
}
pub fn invalidate_display_cache(&mut self) {
self.active_projection_mut().invalidate_display_cache();
}
pub fn profit_histories(&self) -> &HashMap<String, ProfitHistory> {
self.active_projection().profit_histories()
}
pub fn street_dist(&self) -> &StreetDistribution {
self.active_projection().street_dist()
}
pub fn elapsed(&self) -> Duration {
self.start_time.elapsed()
}
pub fn games_per_second(&self) -> f64 {
let secs = self.elapsed().as_secs_f64();
if secs > 0.0 {
self.games_completed() as f64 / secs
} else {
0.0
}
}
pub fn eta(&self) -> Option<Duration> {
let gps = self.games_per_second();
let target = self.games_target?;
if gps <= 0.0 || self.games_completed() >= target {
return None;
}
let remaining = target - self.games_completed();
Some(Duration::from_secs_f64(remaining as f64 / gps))
}
#[cfg(test)]
pub fn set_games_completed(&mut self, n: usize) {
self.base.set_game_count(n);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::projection::MAX_PROFIT_HISTORY;
fn make_stats(num_players: usize) -> StatsStorage {
StatsStorage::new_with_num_players(num_players)
}
fn make_game_result(names: &[&str], profits: &[f32], round: RoundLabel) -> GameResult {
let num_players = names.len();
let mut stats = make_stats(num_players);
for (i, &profit) in profits.iter().enumerate() {
stats.total_profit[i] = profit;
stats.hands_played[i] = 1;
stats.total_invested[i] = 10.0;
if profit > 0.0 {
stats.games_won[i] = 1;
} else if profit < 0.0 {
stats.games_lost[i] = 1;
} else {
stats.games_breakeven[i] = 1;
}
}
let seat_stats: Vec<SeatStats> = (0..num_players)
.map(|i| SeatStats::from_storage(&stats, i))
.collect();
GameResult {
agent_names: names.iter().map(|s| s.to_string()).collect(),
profits: profits.to_vec(),
ending_round: round,
seat_stats,
big_blind: 10.0,
}
}
#[test]
fn test_new_state_is_empty() {
let mut state = TuiState::new(Some(100));
assert_eq!(state.games_completed(), 0);
assert_eq!(state.games_target, Some(100));
assert_eq!(state.street_dist().total(), 0);
assert!(state.agent_display_data().is_empty());
}
#[test]
fn test_update_single_game() {
let mut state = TuiState::new(Some(10));
let result = make_game_result(&["Alice", "Bob"], &[15.0, -15.0], RoundLabel::River);
state.update(&result);
assert_eq!(state.games_completed(), 1);
assert_eq!(state.street_dist().river, 1);
let agents = state.agent_display_data();
assert_eq!(agents.len(), 2);
assert_eq!(agents[0].name, "Alice");
assert_eq!(agents[0].total_profit, 15.0);
assert_eq!(agents[1].name, "Bob");
assert_eq!(agents[1].total_profit, -15.0);
}
#[test]
fn test_update_multiple_games_accumulates() {
let mut state = TuiState::new(None);
state.update(&make_game_result(
&["Alice", "Bob"],
&[10.0, -10.0],
RoundLabel::Flop,
));
state.update(&make_game_result(
&["Alice", "Bob"],
&[-5.0, 5.0],
RoundLabel::River,
));
assert_eq!(state.games_completed(), 2);
let agents = state.agent_display_data();
let alice = agents.iter().find(|a| a.name == "Alice").unwrap();
assert!((alice.total_profit - 5.0).abs() < 0.01);
assert_eq!(alice.games_played, 2);
assert_eq!(alice.wins, 1);
}
#[test]
fn test_agent_profit_history_tracks_running_total() {
let mut state = TuiState::new(None);
state.update(&make_game_result(&["Alice"], &[10.0], RoundLabel::Preflop));
state.update(&make_game_result(&["Alice"], &[-3.0], RoundLabel::Preflop));
state.update(&make_game_result(&["Alice"], &[7.0], RoundLabel::Preflop));
let histories = state.profit_histories();
let alice_history = histories.get("Alice").unwrap();
assert_eq!(alice_history.values.len(), 3);
assert!((alice_history.values[0] - 10.0).abs() < 0.01);
assert!((alice_history.values[1] - 7.0).abs() < 0.01);
assert!((alice_history.values[2] - 14.0).abs() < 0.01);
}
#[test]
fn test_profit_history_tracks_first_game_index_after_eviction() {
let mut state = TuiState::new(None);
let extra = 5;
for _ in 0..(MAX_PROFIT_HISTORY + extra) {
state.update(&make_game_result(&["Alice"], &[1.0], RoundLabel::Preflop));
}
let histories = state.profit_histories();
let alice = histories.get("Alice").unwrap();
assert_eq!(alice.values.len(), MAX_PROFIT_HISTORY);
assert_eq!(alice.first_game_index, extra + 1);
assert_eq!(alice.x_at(0), extra + 1);
assert_eq!(
alice.x_at(alice.values.len() - 1),
MAX_PROFIT_HISTORY + extra
);
}
#[test]
fn test_street_distribution_counts_all_rounds() {
let mut state = TuiState::new(None);
state.update(&make_game_result(&["A"], &[1.0], RoundLabel::Preflop));
state.update(&make_game_result(&["A"], &[1.0], RoundLabel::Flop));
state.update(&make_game_result(&["A"], &[1.0], RoundLabel::Turn));
state.update(&make_game_result(&["A"], &[1.0], RoundLabel::River));
state.update(&make_game_result(&["A"], &[1.0], RoundLabel::Showdown));
assert_eq!(state.street_dist().preflop, 1);
assert_eq!(state.street_dist().flop, 1);
assert_eq!(state.street_dist().turn, 1);
assert_eq!(state.street_dist().river, 1);
assert_eq!(state.street_dist().showdown, 1);
assert_eq!(state.street_dist().total(), 5);
}
#[test]
fn test_progress_calculations() {
let mut state = TuiState::new(Some(100));
for _ in 0..50 {
state.update(&make_game_result(&["A"], &[1.0], RoundLabel::Preflop));
}
assert_eq!(state.games_completed(), 50);
assert!(state.games_per_second() > 0.0);
}
#[test]
fn test_eta_returns_none_when_no_target() {
let state = TuiState::new(None);
assert!(state.eta().is_none());
}
#[test]
fn test_eta_returns_none_when_complete() {
let mut state = TuiState::new(Some(1));
state.update(&make_game_result(&["A"], &[1.0], RoundLabel::Preflop));
assert!(state.eta().is_none());
}
#[test]
fn test_agent_display_data_sorted_by_profit() {
let mut state = TuiState::new(None);
state.update(&make_game_result(
&["Worst", "Best", "Mid"],
&[-10.0, 20.0, 5.0],
RoundLabel::River,
));
let agents = state.agent_display_data();
assert_eq!(agents[0].name, "Best");
assert_eq!(agents[1].name, "Mid");
assert_eq!(agents[2].name, "Worst");
}
#[test]
fn test_duplicate_agent_name_single_profit_history_entry() {
let mut state = TuiState::new(None);
state.update(&make_game_result(
&["Bot", "Bot"],
&[10.0, -10.0],
RoundLabel::River,
));
let agents = state.agent_display_data();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].name, "Bot");
assert!((agents[0].total_profit - 0.0).abs() < 0.01);
let histories = state.profit_histories();
let bot_history = histories.get("Bot").unwrap();
assert_eq!(bot_history.values.len(), 1);
assert!((bot_history.values[0] - 0.0).abs() < 0.01);
assert_eq!(agents[0].games_played, 2);
}
#[test]
fn test_seat_stats_roundtrip() {
let mut source = make_stats(3);
source.total_profit[1] = 42.0;
source.hands_played[1] = 5;
source.games_won[1] = 3;
let seat = SeatStats::from_storage(&source, 1);
assert_eq!(seat.total_profit, 42.0);
assert_eq!(seat.hands_played, 5);
assert_eq!(seat.games_won, 3);
let mut dest = make_stats(1);
seat.merge_into(&mut dest);
assert_eq!(dest.total_profit[0], 42.0);
assert_eq!(dest.hands_played[0], 5);
assert_eq!(dest.games_won[0], 3);
}
#[test]
fn test_panel_cycles_through_three() {
assert_eq!(Panel::Table.next(), Panel::GameLog);
assert_eq!(Panel::GameLog.next(), Panel::Filter);
assert_eq!(Panel::Filter.next(), Panel::Table);
}
#[test]
fn test_filter_state_default_is_inactive() {
let filter = FilterState::default();
assert!(!filter.is_active());
}
#[test]
fn test_filter_toggle_winner() {
let mut filter = FilterState::default();
filter.toggle_winner("Alice");
assert!(filter.is_active());
assert!(filter.winners.contains("Alice"));
filter.toggle_winner("Alice");
assert!(!filter.is_active());
}
#[test]
fn test_filter_toggle_participant() {
let mut filter = FilterState::default();
filter.toggle_participant("Bob");
assert!(filter.participants.contains("Bob"));
filter.toggle_participant("Bob");
assert!(!filter.participants.contains("Bob"));
}
#[test]
fn test_filter_toggle_street() {
let mut filter = FilterState::default();
filter.toggle_street(RoundLabel::Flop);
assert!(filter.streets.contains(&RoundLabel::Flop));
filter.toggle_street(RoundLabel::Flop);
assert!(!filter.streets.contains(&RoundLabel::Flop));
}
#[test]
fn test_filter_clear() {
let mut filter = FilterState::default();
filter.toggle_winner("Alice");
filter.toggle_participant("Bob");
filter.toggle_street(RoundLabel::River);
assert!(filter.is_active());
filter.clear();
assert!(!filter.is_active());
}
fn make_entry(
game_number: usize,
names: &[&str],
profits: &[f32],
round: RoundLabel,
) -> GameLogEntry {
GameLogEntry::new(
game_number,
names.iter().map(|s| s.to_string()).collect(),
profits.to_vec(),
round,
10.0,
)
}
#[test]
fn test_game_log_entry_new_derived_fields() {
let entry = make_entry(1, &["Alice", "Bob"], &[15.0, -15.0], RoundLabel::River);
assert_eq!(entry.winner_name, "Alice");
assert!((entry.winner_profit - 1.5).abs() < 0.01);
assert_eq!(entry.loser_name, "Bob");
assert!((entry.loser_loss - (-1.5)).abs() < 0.01);
assert!((entry.pot_size - 1.5).abs() < 0.01);
}
#[test]
fn test_game_log_entry_three_players() {
let entry = make_entry(
1,
&["A", "B", "C"],
&[20.0, -5.0, -15.0],
RoundLabel::Showdown,
);
assert_eq!(entry.winner_name, "A");
assert!((entry.winner_profit - 2.0).abs() < 0.01);
assert_eq!(entry.loser_name, "C");
assert!((entry.loser_loss - (-1.5)).abs() < 0.01);
assert!((entry.pot_size - 2.0).abs() < 0.01);
}
#[test]
fn test_profit_bucket_from_bb() {
assert_eq!(ProfitBucket::from_bb(0.0), ProfitBucket::Small);
assert_eq!(ProfitBucket::from_bb(4.9), ProfitBucket::Small);
assert_eq!(ProfitBucket::from_bb(5.0), ProfitBucket::Medium);
assert_eq!(ProfitBucket::from_bb(19.9), ProfitBucket::Medium);
assert_eq!(ProfitBucket::from_bb(20.0), ProfitBucket::Large);
assert_eq!(ProfitBucket::from_bb(99.9), ProfitBucket::Large);
assert_eq!(ProfitBucket::from_bb(100.0), ProfitBucket::Huge);
assert_eq!(ProfitBucket::from_bb(500.0), ProfitBucket::Huge);
}
#[test]
fn test_profit_bucket_label() {
assert_eq!(ProfitBucket::Small.label(), "0-5bb");
assert_eq!(ProfitBucket::Medium.label(), "5-20bb");
assert_eq!(ProfitBucket::Large.label(), "20-100bb");
assert_eq!(ProfitBucket::Huge.label(), "100+bb");
}
#[test]
fn test_filter_matches_entry_no_filters() {
let filter = FilterState::default();
let entry = make_entry(1, &["Alice", "Bob"], &[10.0, -10.0], RoundLabel::River);
assert!(filter.matches_entry(&entry));
}
#[test]
fn test_filter_matches_winner() {
let mut filter = FilterState::default();
filter.toggle_winner("Alice");
let entry = make_entry(1, &["Alice", "Bob"], &[10.0, -10.0], RoundLabel::River);
assert!(filter.matches_entry(&entry));
let mut filter2 = FilterState::default();
filter2.toggle_winner("Bob");
assert!(!filter2.matches_entry(&entry));
}
#[test]
fn test_filter_matches_loser() {
let mut filter = FilterState::default();
filter.toggle_loser("Bob");
let entry = make_entry(1, &["Alice", "Bob"], &[10.0, -10.0], RoundLabel::River);
assert!(filter.matches_entry(&entry));
let mut filter2 = FilterState::default();
filter2.toggle_loser("Alice");
assert!(!filter2.matches_entry(&entry));
}
#[test]
fn test_filter_matches_participant() {
let mut filter = FilterState::default();
filter.toggle_participant("Bob");
let entry = make_entry(1, &["Alice", "Bob"], &[10.0, -10.0], RoundLabel::River);
assert!(filter.matches_entry(&entry));
let mut filter2 = FilterState::default();
filter2.toggle_participant("Charlie");
assert!(!filter2.matches_entry(&entry));
}
#[test]
fn test_filter_matches_street() {
let mut filter = FilterState::default();
filter.toggle_street(RoundLabel::River);
let river_entry = make_entry(1, &["A"], &[1.0], RoundLabel::River);
let flop_entry = make_entry(2, &["A"], &[1.0], RoundLabel::Flop);
assert!(filter.matches_entry(&river_entry));
assert!(!filter.matches_entry(&flop_entry));
}
#[test]
fn test_filter_matches_win_size() {
let mut filter = FilterState::default();
filter.toggle_win_size(ProfitBucket::Medium);
let entry = make_entry(1, &["A", "B"], &[10.0, -10.0], RoundLabel::River);
assert!(!filter.matches_entry(&entry));
let entry2 = make_entry(2, &["A", "B"], &[100.0, -100.0], RoundLabel::River);
assert!(filter.matches_entry(&entry2));
}
#[test]
fn test_filter_matches_loss_size() {
let mut filter = FilterState::default();
filter.toggle_loss_size(ProfitBucket::Large);
let entry = make_entry(1, &["A", "B"], &[10.0, -10.0], RoundLabel::River);
assert!(!filter.matches_entry(&entry));
let entry2 = make_entry(2, &["A", "B"], &[500.0, -500.0], RoundLabel::River);
assert!(filter.matches_entry(&entry2));
}
#[test]
fn test_filter_and_semantics() {
let mut filter = FilterState::default();
filter.toggle_winner("Alice");
filter.toggle_street(RoundLabel::River);
let matching = make_entry(1, &["Alice", "Bob"], &[10.0, -10.0], RoundLabel::River);
assert!(filter.matches_entry(&matching));
let wrong_street = make_entry(2, &["Alice", "Bob"], &[10.0, -10.0], RoundLabel::Flop);
assert!(!filter.matches_entry(&wrong_street));
let alice_lost = make_entry(3, &["Alice", "Bob"], &[-10.0, 10.0], RoundLabel::River);
assert!(!filter.matches_entry(&alice_lost));
}
#[test]
fn test_all_agent_names() {
let mut state = TuiState::new(None);
state.update(&make_game_result(
&["Charlie", "Alice"],
&[10.0, -10.0],
RoundLabel::River,
));
state.update(&make_game_result(
&["Bob", "Alice"],
&[5.0, -5.0],
RoundLabel::Flop,
));
let names = state.all_agent_names();
assert_eq!(names, vec!["Alice", "Bob", "Charlie"]);
}
}