- Add blokus_ui package: Flask web app for human-vs-bot Blokus - GameSession: wraps BlokusGame for human play, records move history, serializes - RunStore: file-based JSON replay storage with save/load/list/delete - Flask app: routes for new game, play, save, browse runs, replay - Templates: index (new game + run browser), game (playable board), replay (step controls) - Static: CSS (dark theme, colored cells, placement overlays) + JS (click handling, API calls) - Add blokus_ui tests (19 tests for GameSession and RunStore) - Update pyproject.toml with flask dependency and blokus-ui entry point - Fix: remove unreachable dead code in BlokusGame.valid_move - All 102 tests pass, ruff lint clean
200 lines
6.6 KiB
Python
200 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
from blokus_ui.session import GameSession
|
|
from blokus_ui.store import RunStore
|
|
|
|
|
|
def make_session(**kwargs):
|
|
"""Create a GameSession with sensible defaults for testing."""
|
|
defaults = {
|
|
"board_size": 7,
|
|
"num_players": 2,
|
|
"human_player": 0,
|
|
"bot_type": "random",
|
|
"bot_seed": 42,
|
|
}
|
|
defaults.update(kwargs)
|
|
return GameSession(**defaults)
|
|
|
|
|
|
class TestGameSession:
|
|
def test_creation(self):
|
|
session = make_session()
|
|
assert session.board_size == 7
|
|
assert session.num_players == 2
|
|
assert session.human_player == 0
|
|
assert session.bot_type_name == "random"
|
|
|
|
def test_initial_state(self):
|
|
session = make_session()
|
|
state = session.get_state()
|
|
assert state["board_size"] == 7
|
|
assert state["human_player"] == 0
|
|
assert state["game_over"] is False
|
|
assert len(state["players"]) == 2
|
|
assert len(state["available_pieces"]) > 0
|
|
|
|
def test_valid_placements(self):
|
|
session = make_session()
|
|
placements = session.get_valid_placements(0)
|
|
assert len(placements) > 0
|
|
for p in placements:
|
|
assert "action" in p
|
|
assert "cells" in p
|
|
assert "x" in p
|
|
assert "y" in p
|
|
|
|
def test_apply_human_action(self):
|
|
session = make_session()
|
|
placements = session.get_valid_placements(0)
|
|
action = placements[0]["action"]
|
|
result = session.apply_human_action(action)
|
|
assert result["success"] is True
|
|
|
|
def test_invalid_action(self):
|
|
session = make_session()
|
|
result = session.apply_human_action(999999)
|
|
assert result["success"] is False
|
|
|
|
def test_move_history(self):
|
|
session = make_session()
|
|
placements = session.get_valid_placements(0)
|
|
action = placements[0]["action"]
|
|
session.apply_human_action(action)
|
|
assert len(session.move_history) > 0
|
|
move = session.move_history[0]
|
|
assert move["player_idx"] == 0
|
|
assert "piece_name" in move
|
|
assert "x" in move
|
|
assert "y" in move
|
|
|
|
def test_game_over_detection(self):
|
|
session = make_session(board_size=7)
|
|
steps = 0
|
|
while not session.game_over and steps < 200:
|
|
state = session.get_state()
|
|
moved = False
|
|
for piece in state["available_pieces"]:
|
|
placements = session.get_valid_placements(piece["id"])
|
|
if placements:
|
|
session.apply_human_action(placements[0]["action"])
|
|
moved = True
|
|
break
|
|
if not moved:
|
|
break
|
|
steps += 1
|
|
assert session.game_over is True
|
|
|
|
def test_serialization(self):
|
|
session = make_session()
|
|
placements = session.get_valid_placements(0)
|
|
session.apply_human_action(placements[0]["action"])
|
|
|
|
data = session.to_dict()
|
|
assert "config" in data
|
|
assert "move_history" in data
|
|
assert "game_over" in data
|
|
assert data["config"]["board_size"] == 7
|
|
assert data["config"]["num_players"] == 2
|
|
|
|
def test_deserialization(self):
|
|
session = make_session()
|
|
placements = session.get_valid_placements(0)
|
|
session.apply_human_action(placements[0]["action"])
|
|
|
|
data = session.to_dict()
|
|
restored = GameSession.from_dict(data)
|
|
|
|
assert restored.board_size == session.board_size
|
|
assert restored.num_players == session.num_players
|
|
assert len(restored.move_history) == len(session.move_history)
|
|
assert restored.game_over == session.game_over
|
|
|
|
def test_statistics(self):
|
|
session = make_session()
|
|
placements = session.get_valid_placements(0)
|
|
session.apply_human_action(placements[0]["action"])
|
|
|
|
stats = session.get_statistics()
|
|
assert "coverage" in stats
|
|
assert "pieces_placed" in stats
|
|
assert "squares_placed" in stats
|
|
assert "total_moves" in stats
|
|
assert len(stats["pieces_placed"]) == 2
|
|
assert len(stats["squares_placed"]) == 2
|
|
|
|
def test_four_player_game(self):
|
|
session = make_session(num_players=4)
|
|
state = session.get_state()
|
|
assert len(state["players"]) == 4
|
|
|
|
def test_greedy_bot(self):
|
|
session = make_session(bot_type="greedy")
|
|
placements = session.get_valid_placements(0)
|
|
assert len(placements) > 0
|
|
|
|
def test_minimax_bot(self):
|
|
session = make_session(bot_type="minimax")
|
|
placements = session.get_valid_placements(0)
|
|
assert len(placements) > 0
|
|
|
|
|
|
class TestRunStore:
|
|
def test_save_and_load(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
store = RunStore(runs_dir=tmpdir)
|
|
session = make_session()
|
|
placements = session.get_valid_placements(0)
|
|
session.apply_human_action(placements[0]["action"])
|
|
|
|
run_id = store.save(session, name="test_run")
|
|
assert run_id is not None
|
|
|
|
run_data = store.load(run_id)
|
|
assert run_data is not None
|
|
assert run_data["name"] == "test_run"
|
|
assert run_data["data"]["config"]["board_size"] == 7
|
|
|
|
def test_list_runs(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
store = RunStore(runs_dir=tmpdir)
|
|
session = make_session()
|
|
store.save(session, name="run1")
|
|
|
|
session2 = make_session(bot_type="greedy", bot_seed=43)
|
|
store.save(session2, name="run2")
|
|
|
|
runs = store.list_runs()
|
|
assert len(runs) == 2
|
|
assert runs[0]["name"] in ("run1", "run2")
|
|
assert runs[0]["board_size"] == 7
|
|
|
|
def test_delete_run(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
store = RunStore(runs_dir=tmpdir)
|
|
session = make_session()
|
|
run_id = store.save(session, name="test_run")
|
|
|
|
assert store.delete(run_id) is True
|
|
assert store.load(run_id) is None
|
|
assert store.delete(run_id) is False
|
|
|
|
def test_load_nonexistent(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
store = RunStore(runs_dir=tmpdir)
|
|
assert store.load("nonexistent") is None
|
|
|
|
def test_list_empty(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
store = RunStore(runs_dir=tmpdir)
|
|
assert store.list_runs() == []
|
|
|
|
def test_runs_dir_creation(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
runs_dir = os.path.join(tmpdir, "new_dir")
|
|
RunStore(runs_dir=runs_dir)
|
|
assert os.path.exists(runs_dir)
|