Files
tavolo/server/crates/tavolo-scopone/tests/plugin.rs
T
woggioni 7b3b38ccbc
CI / Build and push docker image (push) Successful in 4m20s
Rewrite the backend in Rust with Axum
Replace the Python/kaya server in server/ with a Rust Cargo workspace of
three crates mirroring the old layout: tavolo-platform (lobby API, live-game
store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions),
tavolo-scopone (pure scopone scientifico rules plus the platform adapter)
and tavolo-app (composition root and the `tavolo` binary with
serve/migrate/backfill-elo).

The HTTP and WebSocket contracts, error messages, Redis key layout and
Postgres schema are unchanged, so the existing web/ frontend and in-flight
games keep working. Notable differences: sessions are JSON under
tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is
ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations
replace aerich, and OpenTelemetry exports Rust process metrics instead of
CPython gc metrics (no Postgres/Redis client instrumentation).

Docker Compose and the Kubernetes manifest now run the single Rust image
(embedded migrations, /static and /assets served by the app); the release CI
keeps building server/Dockerfile. The full test suite (scopone rules,
platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is
ported and runs with cargo test --workspace; Redis/Postgres integration tests
are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
2026-10-02 13:54:55 +00:00

484 lines
16 KiB
Rust

//! `ScoponeEngine` adapter tests: the platform contract over the pure rules.
//!
//! Ported from `tavolo-scopone/tests/test_plugin.py`.
use serde_json::{json, Map, Value};
use tavolo_platform::engine::GameSession;
use tavolo_platform::errors::Error;
use tavolo_platform::{GameEngine, Seat};
use tavolo_scopone::engine as rules;
use tavolo_scopone::state::{Card, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING};
use tavolo_scopone::ScoponeEngine;
fn opts(value: Value) -> Map<String, Value> {
value.as_object().unwrap().clone()
}
fn empty() -> Map<String, Value> {
Map::new()
}
fn capitalize(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn base_session(engine: &ScoponeEngine) -> GameSession {
let mut session = GameSession::new("s1", engine.id(), "CODE01");
session.creator_sub = "alice".to_string();
session.players = vec![Seat {
user_sub: "alice".to_string(),
display_name: "Alice".to_string(),
team: None,
}];
session
}
fn make_session(engine: &ScoponeEngine, options: Map<String, Value>) -> Result<GameSession, Error> {
let mut session = base_session(engine);
engine.create(&mut session, &options)?;
Ok(session)
}
fn started(engine: &ScoponeEngine, options: Map<String, Value>) -> GameSession {
let mut session = make_session(engine, options).unwrap();
for name in ["bob", "carol", "dave"] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
}
session
}
fn state(session: &GameSession) -> &ScoponeState {
session
.state
.as_ref()
.unwrap()
.downcast_ref::<ScoponeState>()
.unwrap()
}
fn state_mut(session: &mut GameSession) -> &mut ScoponeState {
session
.state
.as_mut()
.unwrap()
.downcast_mut::<ScoponeState>()
.unwrap()
}
fn err_message(error: &Error) -> String {
error
.as_game()
.map(|game| game.message.clone())
.unwrap_or_default()
}
/// Extract the error from a failed `make_session` without requiring
/// `GameSession: Debug` (which `unwrap_err` would).
fn session_error(result: Result<GameSession, Error>) -> Error {
match result {
Ok(_) => panic!("expected an error"),
Err(error) => error,
}
}
/// Play legal moves (and acknowledge hand ends) until the match finishes.
fn drive_to_finish(engine: &ScoponeEngine, session: &mut GameSession) -> usize {
let mut moves = 0;
while !engine.is_finished(session) && moves < 200_000 {
let (action, sub, payload): (&str, String, Map<String, Value>) = {
let st = state(session);
if st.phase == PHASE_HAND_END {
let player = st
.players
.iter()
.find(|player| !st.acked.contains(&player.seat))
.unwrap();
("ack", player.sub.clone(), Map::new())
} else {
let player = st
.players
.iter()
.find(|player| player.seat == st.turn)
.unwrap();
let played = player.hand[0].clone();
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
let mut payload = Map::new();
payload.insert("card".to_string(), json!(played.code()));
if let Some(option) = options.first() {
payload.insert(
"capture".to_string(),
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
);
}
("play", player.sub.clone(), payload)
}
};
engine
.handle_action(session, &sub, action, &payload)
.unwrap();
moves += 1;
}
moves
}
// -- create ----------------------------------------------------------------
#[test]
fn create_seats_creator_on_team_a() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, empty()).unwrap();
assert_eq!(Some("A".to_string()), session.players[0].team);
let st = state(&session);
assert_eq!(11, st.target_score);
assert!(st.napola);
}
#[test]
fn create_options() {
let engine = ScoponeEngine::default();
let session =
make_session(&engine, opts(json!({"target_score": 16, "napola": false}))).unwrap();
let st = state(&session);
assert_eq!(16, st.target_score);
assert!(!st.napola);
assert!(!st.asso_pigliatutto);
}
#[test]
fn create_asso_pigliatutto_option() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, opts(json!({"asso_pigliatutto": true}))).unwrap();
assert!(state(&session).asso_pigliatutto);
}
#[test]
fn create_rejects_bad_options() {
let engine = ScoponeEngine::default();
assert_eq!(
"target_score must be between 1 and 100",
err_message(&session_error(make_session(
&engine,
opts(json!({"target_score": 0}))
)))
);
assert_eq!(
"target_score must be an integer",
err_message(&session_error(make_session(
&engine,
opts(json!({"target_score": "eleven"}))
)))
);
assert_eq!(
"napola must be a boolean",
err_message(&session_error(make_session(
&engine,
opts(json!({"napola": "yes"}))
)))
);
assert_eq!(
"asso_pigliatutto must be a boolean",
err_message(&session_error(make_session(
&engine,
opts(json!({"asso_pigliatutto": "yes"}))
)))
);
}
#[test]
fn timeouts_come_from_the_engine() {
let engine = ScoponeEngine::new(7, 9);
let session = make_session(&engine, empty()).unwrap();
let st = state(&session);
assert_eq!(7, st.turn_timeout);
assert_eq!(9, st.hand_ack_timeout);
}
// -- join ------------------------------------------------------------------
#[test]
fn join_assigns_teams_and_starts() {
let engine = ScoponeEngine::default();
let mut session = make_session(&engine, empty()).unwrap();
assert!(engine.in_lobby(&session));
for (name, team) in [("bob", "B"), ("carol", "A"), ("dave", "B")] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
assert_eq!(Some(team.to_string()), session.players.last().unwrap().team);
}
assert!(!engine.in_lobby(&session));
assert_eq!(PHASE_PLAYING, state(&session).phase);
}
#[test]
fn join_errors() {
let engine = ScoponeEngine::default();
let mut session = make_session(&engine, empty()).unwrap();
assert_eq!(
"already joined this game",
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
);
for name in ["bob", "carol", "dave"] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
}
// The lobby filled up and the match started: late joins and even re-joins
// are rejected as "already started".
assert_eq!(
"game has already started",
err_message(&engine.join(&mut session, "erin", "Erin").unwrap_err())
);
assert_eq!(
"game has already started",
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
);
}
// -- actions ---------------------------------------------------------------
#[test]
fn unknown_action_rejected() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
assert!(engine
.handle_action(&mut session, "alice", "dance", &Map::new())
.is_err());
}
#[test]
fn play_validates_payload() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
assert!(engine
.handle_action(&mut session, "bob", "play", &opts(json!({"card": 42})))
.is_err());
assert!(engine
.handle_action(&mut session, "bob", "play", &Map::new())
.is_err());
assert!(engine
.handle_action(
&mut session,
"bob",
"play",
&opts(json!({"card": "01D", "capture": "02C"}))
)
.is_err());
}
#[test]
fn invalid_card_code_is_illegal_move() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let error = engine
.handle_action(&mut session, "bob", "play", &opts(json!({"card": "nope"})))
.unwrap_err();
assert_eq!("invalid card code", err_message(&error));
}
#[test]
fn finished_match_rejects_actions() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, opts(json!({"target_score": 1})));
drive_to_finish(&engine, &mut session);
assert!(engine.is_finished(&session));
let error = engine
.handle_action(&mut session, "alice", "play", &opts(json!({"card": "01D"})))
.unwrap_err();
assert_eq!("the match is over", err_message(&error));
}
// -- views -----------------------------------------------------------------
#[test]
fn view_for_hides_other_hands() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
let view = engine.view_for(&session, "alice");
let players = view["players"].as_array().unwrap();
let alice = players.iter().find(|p| p["seat"] == 0).unwrap();
let bob = players.iter().find(|p| p["seat"] == 1).unwrap();
assert!(alice.get("hand").is_some());
assert!(bob.get("hand").is_none());
// The envelope is the platform's job, not the view's.
assert!(view.get("id").is_none());
assert!(view.get("join_code").is_none());
assert!(view.get("game_type").is_none());
}
#[test]
fn lobby_view() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, opts(json!({"target_score": 16}))).unwrap();
let lobby = engine.lobby_view(&session);
assert_eq!(json!("lobby"), lobby["phase"]);
assert_eq!(json!(16), lobby["target_score"]);
assert_eq!(json!(true), lobby["napola"]);
assert_eq!(json!(false), lobby["asso_pigliatutto"]);
}
// -- serialization ---------------------------------------------------------
#[test]
fn state_roundtrip() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
let state_value = engine
.state_to_json(session.state.as_ref().unwrap().as_ref())
.unwrap();
let restored = engine.state_from_json(&state_value).unwrap();
let restored = restored.downcast_ref::<ScoponeState>().unwrap();
assert_eq!(state(&session).phase, restored.phase);
assert_eq!(state(&session).turn, restored.turn);
assert_eq!(
state(&session)
.players
.iter()
.map(|p| p.sub.clone())
.collect::<Vec<_>>(),
restored
.players
.iter()
.map(|p| p.sub.clone())
.collect::<Vec<_>>()
);
}
// -- deadlines -------------------------------------------------------------
#[test]
fn no_deadline_in_lobby() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, empty()).unwrap();
assert!(engine.next_deadline(&session).unwrap().is_none());
}
#[test]
fn turn_deadline_and_revalidation() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let deadline = engine.next_deadline(&session).unwrap().unwrap();
assert_eq!("turn", deadline.kind);
// A forged token is stale.
assert!(engine
.fire_deadline(&mut session, "turn", "turn:1:1:0")
.is_err());
let turn_before = state(&session).turn;
engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.unwrap();
assert_ne!(turn_before, state(&session).turn);
}
#[test]
fn stale_deadline_after_play() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let deadline = engine.next_deadline(&session).unwrap().unwrap();
// A play lands in time: the armed deadline is overtaken.
let (sub, payload) = {
let st = state(&session);
let player = st.players.iter().find(|p| p.seat == st.turn).unwrap();
let played = player.hand[0].clone();
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
let mut payload = Map::new();
payload.insert("card".to_string(), json!(played.code()));
if let Some(option) = options.first() {
payload.insert(
"capture".to_string(),
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
);
}
(player.sub.clone(), payload)
};
engine
.handle_action(&mut session, &sub, "play", &payload)
.unwrap();
assert!(engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.is_err());
}
#[test]
fn hand_end_deadline_advances() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
{
let st = state_mut(&mut session);
st.phase = PHASE_HAND_END.to_string();
st.hand_end_deadline = Some(tavolo_platform::datetime::to_iso(
tavolo_platform::datetime::now() + chrono::Duration::seconds(60),
));
}
let deadline = engine.next_deadline(&session).unwrap().unwrap();
assert_eq!("hand_end", deadline.kind);
engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.unwrap();
let st = state(&session);
assert_eq!(PHASE_PLAYING, st.phase);
assert_eq!(2, st.hand_number);
}
// -- results ---------------------------------------------------------------
#[test]
fn result_of_finished_match() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, opts(json!({"target_score": 1})));
drive_to_finish(&engine, &mut session);
let result = engine.result(&session).unwrap();
assert_eq!(2, result.teams.len());
assert!(result.winner_team == 0 || result.winner_team == 1);
assert_eq!(4, result.players.len());
let winner_name = if result.winner_team == 0 { "A" } else { "B" };
for player in &result.players {
assert_eq!(player.won, player.team.as_deref() == Some(winner_name));
}
assert!(result.summary.get("team_a_score").is_some());
assert!(result.summary.get("hands_played").is_some());
}
#[test]
fn result_requires_finished_match() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
assert!(engine.result(&session).is_err());
}
#[test]
fn game_over_view() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
{
let st = state_mut(&mut session);
st.phase = PHASE_FINISHED.to_string();
st.winner = Some(1);
st.scores = vec![3, 11];
}
let over = engine.game_over_view(&session, "alice");
assert_eq!(json!({"A": 3, "B": 11}), over["scores"]);
assert_eq!(json!("B"), over["winner"]);
}
#[test]
fn registry_metadata() {
let engine = ScoponeEngine::default();
assert_eq!("scopone_scientifico", engine.id());
assert_eq!(4, engine.min_players());
assert_eq!(4, engine.max_players());
let schema = engine.options_schema();
assert!(schema["properties"]["target_score"].is_object());
assert!(schema["properties"]["napola"].is_object());
assert!(schema["properties"]["asso_pigliatutto"].is_object());
// Byte-compatible option schema: descriptions and defaults match Python.
assert_eq!(
json!("Score the napola rule; a full denari sweep wins the match"),
schema["properties"]["napola"]["description"]
);
assert_eq!(
json!("Score the asso pigliatutto rule; an ace sweeps the whole table (no scopa)"),
schema["properties"]["asso_pigliatutto"]["description"]
);
}