#[cfg(test)]
mod unit_tests {
use crate::action::Phase;
use crate::agari::{is_agari, is_chiitoitsu, is_kokushi};
use crate::score::calculate_score;
use crate::types::Hand;
#[test]
fn test_agari_standard() {
let tiles = [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 18, 18, ];
let mut hand = Hand::new(Some(tiles.to_vec()));
assert!(is_agari(&mut hand), "Should be agari");
}
#[test]
fn test_basic_pinfu() {
let mut hand = Hand::new(None);
hand.add(0);
hand.add(1);
hand.add(2);
hand.add(3);
hand.add(4);
hand.add(5);
hand.add(6);
hand.add(7);
hand.add(8);
hand.add(9);
hand.add(10);
hand.add(11);
hand.add(18);
hand.add(18);
assert!(is_agari(&mut hand));
}
#[test]
fn test_chiitoitsu() {
let mut hand = Hand::new(None);
let pairs = [0, 2, 4, 6, 8, 10, 12];
for &t in &pairs {
hand.add(t);
hand.add(t);
}
assert!(is_chiitoitsu(&hand));
assert!(is_agari(&mut hand));
}
#[test]
fn test_kokushi() {
let mut hand = Hand::new(None);
let terminals = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33];
for &t in &terminals {
hand.add(t);
}
hand.add(0); assert!(is_kokushi(&hand));
assert!(is_agari(&mut hand));
}
#[test]
fn test_score_calculation() {
let score = calculate_score(4, 30, false, true, 0);
assert_eq!(score.pay_tsumo_oya, 3900);
assert_eq!(score.pay_tsumo_ko, 2000);
assert_eq!(score.total, 7900); }
#[test]
fn test_tsuu_iisou() {
use crate::yaku::{calculate_yaku, YakuContext};
let mut hand = Hand::new(None);
for &t in &[27, 28, 29, 30] {
hand.add(t);
hand.add(t);
hand.add(t);
}
hand.add(31);
hand.add(31);
let res = calculate_yaku(&hand, &[], &YakuContext::default(), 31);
assert!(res.han >= 13);
assert!(res.yaku_ids.contains(&39));
}
#[test]
fn test_ryuu_iisou() {
use crate::yaku::{calculate_yaku, YakuContext};
let mut hand = Hand::new(None);
let tiles = [
19, 20, 21, 23, 23, 23, 25, 25, 25, 32, 32, 32, 19, 19, ];
for &t in &tiles {
hand.add(t);
}
let res = calculate_yaku(&hand, &[], &YakuContext::default(), 19);
assert!(res.han >= 13);
assert!(res.yaku_ids.contains(&40));
}
#[test]
fn test_daisushii() {
use crate::yaku::{calculate_yaku, YakuContext};
let mut hand = Hand::new(None);
for &t in &[27, 28, 29, 30] {
hand.add(t);
hand.add(t);
hand.add(t);
}
hand.add(0);
hand.add(0);
let res = calculate_yaku(&hand, &[], &YakuContext::default(), 0);
assert!(res.han >= 26);
assert!(res.yaku_ids.contains(&50));
}
fn create_test_state(game_type: u8) -> crate::state::GameState {
crate::state::GameState::new(game_type, false, None, 0, crate::rule::GameRule::default())
}
#[test]
fn test_seeded_shuffle_changes_between_rounds() {
let mut state = create_test_state(2);
state.seed = Some(42);
state._initialize_next_round(true, false);
let digest1 = state.wall.wall_digest.clone();
state._initialize_next_round(true, false);
let digest2 = state.wall.wall_digest.clone();
assert_ne!(
digest1, digest2,
"Wall digest should differ between rounds when seed is fixed"
);
}
#[test]
fn test_sudden_death_hanchan_logic() {
use serde_json::Value;
let mut state = create_test_state(2);
state.round_wind = 1;
state.kyoku_idx = 3;
state.oya = 3;
for i in 0..4 {
state.players[i].score = 25000;
state.players[i].nagashi_eligible = false;
}
state.needs_initialize_next_round = false;
state._trigger_ryukyoku("exhaustive_draw");
if state.needs_initialize_next_round {
state._initialize_next_round(state.pending_oya_won, state.pending_is_draw);
state.needs_initialize_next_round = false;
}
assert!(
!state.is_done,
"Game should not be done (Sudden Death should trigger)"
);
assert_eq!(state.round_wind, 2, "Should enter West round");
assert_eq!(state.kyoku_idx, 0, "Should be West 1 (Kyoku 0)");
assert_eq!(state.oya, 0, "Oya should rotate to player 0");
let new_scores = [31000, 25000, 24000, 20000];
for (player, &score) in state.players.iter_mut().zip(new_scores.iter()) {
player.score = score;
}
state._trigger_ryukyoku("exhaustive_draw");
if state.needs_initialize_next_round {
state._initialize_next_round(state.pending_oya_won, state.pending_is_draw);
state.needs_initialize_next_round = false;
}
assert!(
state.is_done,
"Game should be done (Score >= 30000 in West)"
);
let logs = &state.mjai_log;
let event_types: Vec<String> = logs
.iter()
.filter_map(|s| {
let v: Value = serde_json::from_str(s).ok()?;
v.get("type")
.and_then(|t| t.as_str())
.map(|t| t.to_string())
})
.collect();
let last_event = event_types.last().expect("Should have events");
assert_eq!(last_event, "end_game");
assert!(event_types.contains(&"ryukyoku".to_string()));
}
#[test]
fn test_is_tenpai() {
use crate::hand_evaluator::HandEvaluator;
let hand = vec![0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14, 72];
let calc = HandEvaluator::new(hand, Vec::new());
assert!(calc.is_tenpai());
let waits = calc.get_waits_u8();
assert!(waits.contains(&18)); }
#[test]
fn test_kuikae_deadlock_repro() {
use crate::action::{Action, ActionType};
use std::collections::HashMap;
let mut state = create_test_state(4);
let pid = 0;
state.players[pid as usize].hand = vec![12, 16, 20, 21];
state.current_player = 3;
state.phase = Phase::WaitAct;
state.active_players = vec![3];
state.players[3].hand.push(8);
let mut actions = HashMap::new();
actions.insert(3, Action::new(ActionType::Discard, Some(8), vec![]));
state.step(&actions);
state.step(&actions);
assert_eq!(
state.phase,
Phase::WaitAct,
"Should proceed to WaitAct as deadlock Chi is filtered out"
);
assert_eq!(state.current_player, 0, "Should be P0's turn");
if let Some(claims) = state.current_claims.get(&0) {
assert!(claims.is_empty(), "P0 should have no legal claims");
}
}
#[test]
fn test_match_84_agari_check() {
use crate::hand_evaluator::HandEvaluator;
use crate::types::{Conditions, Wind};
let mut tiles = vec![
0, 1, 2, 60, 64, 72, 73, 74, 76, 80, 96, 100, 104, ];
tiles.sort();
let calc = HandEvaluator::new(tiles, Vec::new());
let cond = Conditions {
tsumo: false,
riichi: false,
double_riichi: false,
ippatsu: false,
haitei: false,
houtei: false,
rinshan: false,
chankan: false,
tsumo_first_turn: false,
player_wind: Wind::West,
round_wind: Wind::East,
riichi_sticks: 0,
honba: 0,
};
let res6p = calc.calc(56, vec![], vec![], Some(cond.clone()));
println!(
"6p Result: is_win={}, Shape={}, Han={}, Yaku={:?}",
res6p.is_win, res6p.has_win_shape, res6p.han, res6p.yaku
);
assert!(!res6p.is_win, "6p should NOT be a win (No Yaku)");
assert!(res6p.has_win_shape, "6p should have win shape");
assert_eq!(res6p.han, 0, "6p should have 0 Han");
let res9p = calc.calc(68, vec![], vec![], Some(cond));
println!(
"9p Result: is_win={}, Han={}, Yaku={:?}",
res9p.is_win, res9p.han, res9p.yaku
);
assert!(res9p.is_win, "9p should be a win");
assert!(res9p.han >= 3, "9p should be Junchan (>= 3 Han)"); }
#[test]
fn test_tobi_ends_game() {
let mut state = create_test_state(4);
state.game_mode = 2;
state.players[0].score = 30000;
state.players[1].score = 40000;
state.players[2].score = 35000;
state.players[3].score = -5000;
state.needs_initialize_next_round = false;
state._initialize_next_round(false, false);
assert!(
state.is_done,
"Game should be done due to tobi (player with negative score)"
);
}
#[test]
fn test_apply_mjai_event_honor_and_red_tiles() {
use crate::replay::MjaiEvent;
let mut state =
crate::state::GameState::new(4, true, None, 0, crate::rule::GameRule::default());
let start = MjaiEvent::StartKyoku {
bakaze: "E".to_string(),
kyoku: 1,
honba: 0,
kyoutaku: 0,
oya: 0,
scores: vec![25000, 25000, 25000, 25000],
dora_marker: "P".to_string(), tehais: vec![
vec![
"E", "S", "W", "N", "P", "F", "C", "1m", "2m", "3m", "4m", "5m", "6m",
]
.into_iter()
.map(String::from)
.collect(),
vec![
"1s", "2s", "3s", "4s", "5sr", "6s", "7s", "8s", "9s", "1p", "2p", "3p", "4p",
]
.into_iter()
.map(String::from)
.collect(),
vec![
"5pr", "1m", "2m", "3m", "4m", "6m", "7m", "8m", "9m", "1p", "2p", "3p", "4p",
]
.into_iter()
.map(String::from)
.collect(),
vec![
"1s", "2s", "3s", "4s", "5s", "6s", "7s", "8s", "9s", "1m", "2m", "3m", "4m",
]
.into_iter()
.map(String::from)
.collect(),
],
};
state.apply_mjai_event(start);
let hand0 = &state.players[0].hand;
assert!(
hand0.contains(&108),
"E should be tid 108, hand: {:?}",
hand0
);
assert!(
hand0.contains(&112),
"S should be tid 112, hand: {:?}",
hand0
);
assert!(
hand0.contains(&116),
"W should be tid 116, hand: {:?}",
hand0
);
assert!(
hand0.contains(&120),
"N should be tid 120, hand: {:?}",
hand0
);
assert!(
hand0.contains(&124),
"P should be tid 124, hand: {:?}",
hand0
);
assert!(
hand0.contains(&128),
"F should be tid 128, hand: {:?}",
hand0
);
assert!(
hand0.contains(&132),
"C should be tid 132, hand: {:?}",
hand0
);
let hand1 = &state.players[1].hand;
assert!(
hand1.contains(&88),
"5sr should be tid 88, hand: {:?}",
hand1
);
let hand2 = &state.players[2].hand;
assert!(
hand2.contains(&52),
"5pr should be tid 52, hand: {:?}",
hand2
);
assert_eq!(
state.wall.dora_indicators[0], 124,
"dora_marker P should be tid 124, got: {}",
state.wall.dora_indicators[0]
);
let tsumo = MjaiEvent::Tsumo {
actor: 0,
pai: "C".to_string(), };
state.apply_mjai_event(tsumo);
assert!(
state.players[0].hand.contains(&132),
"Tsumo C should add tid 132 to hand, hand: {:?}",
state.players[0].hand
);
let dahai = MjaiEvent::Dahai {
actor: 0,
pai: "E".to_string(), tsumogiri: false,
};
state.apply_mjai_event(dahai);
assert!(
state.players[0].discards.contains(&108),
"Dahai E should discard tid 108, discards: {:?}",
state.players[0].discards
);
let dora = MjaiEvent::Dora {
dora_marker: "F".to_string(), };
state.apply_mjai_event(dora);
assert_eq!(
state.wall.dora_indicators[1], 128,
"dora F should be tid 128, got: {}",
state.wall.dora_indicators[1]
);
}
#[test]
fn test_no_tobi_with_positive_scores() {
let mut state = create_test_state(4);
state.game_mode = 2; state.round_wind = 0;
state.players[0].score = 25000;
state.players[1].score = 25000;
state.players[2].score = 25000;
state.players[3].score = 25000;
state.needs_initialize_next_round = false;
state._initialize_next_round(false, false);
assert!(
!state.is_done,
"Game should NOT be done (all players have positive scores)"
);
}
}