Files
tavolo/server/tests/test_store.py
T
woggioni 2d1a13f663 Drive timeouts from a shared Redis deadline queue
Turn auto-play and hand-end auto-continue were process-local asyncio
tasks armed only by client connects and state broadcasts: with no
sockets connected the next turn's timer was never armed, a hand-end
timer died with its worker, and neither survived a pod restart.

Deadlines are now driven by the absolute timestamps persisted on the
game state and enqueued in a shared Redis sorted set. Every worker runs
a consumer that fires due entries under the per-game lock after
revalidating them against the live state, so timeouts no longer depend
on any player being connected and survive the death of any worker.
Delivery is at-least-once: entries are removed only after processing,
and revalidation makes duplicate deliveries no-ops.

Queue entries carry the deadline as integer epoch milliseconds, which
also serves as the revalidation token, and the score derives from the
same value.
2026-09-18 19:16:07 +08:00

137 lines
4.8 KiB
Python

"""In-memory game store behaviour (the Redis store shares this interface)."""
from __future__ import annotations
import asyncio
import unittest
from pwo import async_test
from tavolo.game import engine
from tavolo.store import InMemoryGameStore
class InMemoryGameStoreTest(unittest.TestCase):
@async_test
async def test_save_load_roundtrip(self) -> None:
store = InMemoryGameStore()
state = engine.create_game("g1", "CODE01", "alice", "alice", target_score=16)
engine.join_game(state, "bob", "bob")
await store.save(state)
loaded = await store.load("g1")
self.assertIsNotNone(loaded)
assert loaded is not None
self.assertEqual("CODE01", loaded.join_code)
self.assertEqual(16, loaded.target_score)
self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players])
@async_test
async def test_game_type_roundtrip_and_default(self) -> None:
store = InMemoryGameStore()
state = engine.create_game(
"g1b", "CODE1B", "alice", "alice", game_type="scopone_scientifico"
)
await store.save(state)
loaded = await store.load("g1b")
assert loaded is not None
self.assertEqual("scopone_scientifico", loaded.game_type)
# States serialized before game types existed load with the default.
legacy = state.to_json()
del legacy["game_type"]
from tavolo.game.state import GameState
self.assertEqual("scopone_scientifico", GameState.from_json(legacy).game_type)
@async_test
async def test_load_missing_returns_none(self) -> None:
store = InMemoryGameStore()
self.assertIsNone(await store.load("nope"))
self.assertIsNone(await store.find_by_code("NOPE01"))
@async_test
async def test_find_by_code(self) -> None:
store = InMemoryGameStore()
state = engine.create_game("g2", "CODE02", "alice", "alice")
await store.save(state)
found = await store.find_by_code("code02") # case-insensitive
self.assertIsNotNone(found)
assert found is not None
self.assertEqual("g2", found.id)
@async_test
async def test_load_returns_a_copy(self) -> None:
store = InMemoryGameStore()
state = engine.create_game("g3", "CODE03", "alice", "alice")
await store.save(state)
first = await store.load("g3")
assert first is not None
first.phase = "tampered"
second = await store.load("g3")
assert second is not None
self.assertEqual("lobby", second.phase)
@async_test
async def test_publish_reaches_subscriber(self) -> None:
store = InMemoryGameStore()
state = engine.create_game("g4", "CODE04", "alice", "alice")
await store.save(state)
received = []
async with store.subscribe("g4") as events:
await store.publish("g4")
async for _ in events:
received.append(True)
break
self.assertEqual([True], received)
@async_test
async def test_lock_serializes_concurrent_mutations(self) -> None:
store = InMemoryGameStore()
order = []
async def holder() -> None:
async with store.lock("g5"):
order.append("holder-enter")
await asyncio.sleep(0.05)
order.append("holder-exit")
async def contender() -> None:
await asyncio.sleep(0.01)
async with store.lock("g5"):
order.append("contender")
await asyncio.gather(holder(), contender())
self.assertEqual(
["holder-enter", "holder-exit", "contender"], order
)
@async_test
async def test_deadline_queue(self) -> None:
store = InMemoryGameStore()
self.assertIsNone(await store.next_deadline())
self.assertEqual([], await store.due_deadlines(now=100.0))
await store.add_deadline("b", due_at=50.0)
await store.add_deadline("a", due_at=10.0)
await store.add_deadline("c", due_at=200.0)
# Re-adding an existing member only updates its due time.
await store.add_deadline("b", due_at=60.0)
self.assertEqual(10.0, await store.next_deadline())
self.assertEqual(["a"], await store.due_deadlines(now=10.0))
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
# Due entries come out in due-time order and stay queued until removed.
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
await store.remove_deadline("a")
await store.remove_deadline("a") # removing twice is a no-op
self.assertEqual(60.0, await store.next_deadline())
self.assertEqual(["b"], await store.due_deadlines(now=100.0))
if __name__ == "__main__":
unittest.main()