diff --git a/src/shellmate/ai/engine.py b/src/shellmate/ai/engine.py index 44a8ef6..0da91ab 100644 --- a/src/shellmate/ai/engine.py +++ b/src/shellmate/ai/engine.py @@ -19,7 +19,7 @@ class MoveAnalysis: class ChessAI: """Chess AI powered by Stockfish with explanations.""" - + def __init__( self, stockfish_path: str = "/usr/bin/stockfish", @@ -30,35 +30,35 @@ class ChessAI: self.engine.set_skill_level(skill_level) self.think_time_ms = think_time_ms self._skill_level = skill_level - + def set_difficulty(self, level: int) -> None: """Set AI difficulty (0-20).""" self._skill_level = max(0, min(20, level)) self.engine.set_skill_level(self._skill_level) - + def get_best_move(self, fen: str) -> str: """Get the best move for the current position.""" self.engine.set_fen_position(fen) return self.engine.get_best_move_time(self.think_time_ms) - + def analyze_position(self, fen: str, depth: int = 15) -> MoveAnalysis: """Analyze a position and return detailed analysis.""" self.engine.set_fen_position(fen) self.engine.set_depth(depth) - + evaluation = self.engine.get_evaluation() best_move = self.engine.get_best_move() top_moves = self.engine.get_top_moves(3) - + # Convert evaluation to centipawns if evaluation["type"] == "cp": eval_cp = evaluation["value"] else: # mate eval_cp = 10000 if evaluation["value"] > 0 else -10000 - + # Generate explanation explanation = self._generate_explanation(fen, best_move, eval_cp, top_moves) - + return MoveAnalysis( best_move=best_move, evaluation=eval_cp / 100, # convert to pawns @@ -66,7 +66,7 @@ class ChessAI: pv=[m["Move"] for m in top_moves] if top_moves else [best_move], explanation=explanation, ) - + def _generate_explanation( self, fen: str, @@ -79,19 +79,19 @@ class ChessAI: move = chess.Move.from_uci(best_move) san = board.san(move) piece = board.piece_at(move.from_square) - + explanations = [] - + # Describe the move piece_name = chess.piece_name(piece.piece_type).capitalize() if piece else "Piece" explanations.append(f"{piece_name} to {chess.square_name(move.to_square)} ({san})") - + # Check for captures if board.is_capture(move): captured = board.piece_at(move.to_square) if captured: explanations.append(f"Captures {chess.piece_name(captured.piece_type)}") - + # Check for checks board.push(move) if board.is_check(): @@ -100,7 +100,7 @@ class ChessAI: else: explanations.append("Puts the king in check") board.pop() - + # Evaluation context if abs(eval_cp) < 50: explanations.append("Position is roughly equal") @@ -112,22 +112,22 @@ class ChessAI: explanations.append("White is slightly better") else: explanations.append("Black is slightly better") - + return ". ".join(explanations) + "." - + def explain_move(self, fen_before: str, move_uci: str) -> str: """Explain why a specific move is good or bad.""" analysis_before = self.analyze_position(fen_before) - + board = chess.Board(fen_before) move = chess.Move.from_uci(move_uci) san = board.san(move) board.push(move) - + analysis_after = self.analyze_position(board.fen()) - + eval_change = analysis_before.evaluation - analysis_after.evaluation - + if move_uci == analysis_before.best_move: quality = "This is the best move in this position!" elif abs(eval_change) < 0.3: @@ -138,21 +138,21 @@ class ChessAI: quality = "A slight inaccuracy - there was a better option." else: quality = "A good move!" - + return f"{san}: {quality} {analysis_before.explanation}" - + def get_hint(self, fen: str) -> str: """Get a hint for the current position.""" analysis = self.analyze_position(fen) board = chess.Board(fen) move = chess.Move.from_uci(analysis.best_move) piece = board.piece_at(move.from_square) - + if piece: piece_name = chess.piece_name(piece.piece_type).capitalize() return f"Consider moving your {piece_name}..." return "Look for tactical opportunities..." - + def close(self) -> None: """Clean up engine resources.""" del self.engine diff --git a/src/shellmate/cli.py b/src/shellmate/cli.py index 9e0b110..308656e 100644 --- a/src/shellmate/cli.py +++ b/src/shellmate/cli.py @@ -16,20 +16,20 @@ Examples: shellmate --mode learn Start tutorial mode """ ) - + parser.add_argument( "--mode", "-m", choices=["play", "ai", "learn", "watch"], default="play", help="Game mode (default: play)" ) - + parser.add_argument( "--username", "-u", default="guest", help="Username (default: guest)" ) - + parser.add_argument( "--difficulty", "-d", type=int, @@ -38,20 +38,20 @@ Examples: metavar="0-20", help="AI difficulty level (default: 10)" ) - + parser.add_argument( "--version", "-v", action="store_true", help="Show version" ) - + args = parser.parse_args() - + if args.version: from shellmate import __version__ print(f"ShellMate v{__version__}") sys.exit(0) - + # Launch TUI from shellmate.tui.app import ShellMateApp app = ShellMateApp(username=args.username, mode=args.mode) diff --git a/src/shellmate/core/game.py b/src/shellmate/core/game.py index f6c2328..18b2850 100644 --- a/src/shellmate/core/game.py +++ b/src/shellmate/core/game.py @@ -32,7 +32,7 @@ class Move: @dataclass class ChessGame: """Chess game instance.""" - + id: str white_player_id: str black_player_id: str @@ -40,29 +40,29 @@ class ChessGame: moves: list[Move] = field(default_factory=list) result: GameResult = GameResult.IN_PROGRESS created_at: datetime = field(default_factory=datetime.utcnow) - + @property def current_turn(self) -> str: """Return whose turn it is.""" return "white" if self.board.turn == chess.WHITE else "black" - + @property def current_player_id(self) -> str: """Return the ID of the player whose turn it is.""" return self.white_player_id if self.board.turn == chess.WHITE else self.black_player_id - + def make_move(self, uci: str) -> Optional[Move]: """Make a move and return Move object if valid.""" try: chess_move = chess.Move.from_uci(uci) if chess_move not in self.board.legal_moves: return None - + fen_before = self.board.fen() san = self.board.san(chess_move) self.board.push(chess_move) fen_after = self.board.fen() - + move = Move( uci=uci, san=san, @@ -74,15 +74,15 @@ class ChessGame: return move except (ValueError, chess.InvalidMoveError): return None - + def get_legal_moves(self) -> list[str]: """Return list of legal moves in UCI format.""" return [move.uci() for move in self.board.legal_moves] - + def get_legal_moves_san(self) -> list[str]: """Return list of legal moves in SAN format.""" return [self.board.san(move) for move in self.board.legal_moves] - + def _check_game_end(self) -> None: """Check if the game has ended and set result.""" if self.board.is_checkmate(): @@ -91,31 +91,31 @@ class ChessGame: self.result = GameResult.DRAW elif self.board.can_claim_draw(): self.result = GameResult.DRAW - + def is_check(self) -> bool: """Return True if current player is in check.""" return self.board.is_check() - + def is_game_over(self) -> bool: """Return True if the game is over.""" return self.result != GameResult.IN_PROGRESS - + def to_pgn(self) -> str: """Export game as PGN string.""" game = chess.pgn.Game() game.headers["White"] = self.white_player_id game.headers["Black"] = self.black_player_id game.headers["Date"] = self.created_at.strftime("%Y.%m.%d") - + node = game temp_board = chess.Board() for move in self.moves: chess_move = chess.Move.from_uci(move.uci) node = node.add_variation(chess_move) temp_board.push(chess_move) - + return str(game) - + def get_board_display(self, perspective: str = "white") -> str: """Return ASCII board from given perspective.""" board_str = str(self.board) diff --git a/src/shellmate/core/player.py b/src/shellmate/core/player.py index e3127cc..06d937f 100644 --- a/src/shellmate/core/player.py +++ b/src/shellmate/core/player.py @@ -15,7 +15,7 @@ class PlayerType(Enum): @dataclass class Player: """Represents a player.""" - + id: str username: str player_type: PlayerType = PlayerType.HUMAN @@ -26,14 +26,14 @@ class Player: draws: int = 0 created_at: datetime = field(default_factory=datetime.utcnow) last_seen: Optional[datetime] = None - + @property def winrate(self) -> float: """Calculate win rate percentage.""" if self.games_played == 0: return 0.0 return (self.wins / self.games_played) * 100 - + def update_elo(self, opponent_elo: int, result: float, k: int = 32) -> int: """ Update ELO rating based on game result. @@ -44,7 +44,7 @@ class Player: change = int(k * (result - expected)) self.elo += change return change - + def record_game(self, won: bool, draw: bool = False) -> None: """Record a completed game.""" self.games_played += 1 @@ -57,12 +57,12 @@ class Player: self.last_seen = datetime.utcnow() -@dataclass +@dataclass class AIPlayer(Player): """AI player with configurable difficulty.""" - + difficulty: int = 10 # Stockfish skill level 0-20 think_time_ms: int = 1000 # Time to think per move - + def __post_init__(self): self.player_type = PlayerType.AI diff --git a/src/shellmate/ssh/server.py b/src/shellmate/ssh/server.py index c4aacc1..e47db85 100644 --- a/src/shellmate/ssh/server.py +++ b/src/shellmate/ssh/server.py @@ -14,48 +14,48 @@ logger = logging.getLogger(__name__) class ShellMateSSHServer(asyncssh.SSHServer): """SSH server that launches ShellMate TUI for each connection.""" - + def __init__(self): self._username: Optional[str] = None - + def connection_made(self, conn: asyncssh.SSHServerConnection) -> None: peername = conn.get_extra_info('peername') logger.info(f"SSH connection from {peername}") - + def connection_lost(self, exc: Optional[Exception]) -> None: if exc: logger.error(f"SSH connection error: {exc}") else: logger.info("SSH connection closed") - + def begin_auth(self, username: str) -> bool: self._username = username # No auth required - instant connection return False - + def password_auth_supported(self) -> bool: return True - + def validate_password(self, username: str, password: str) -> bool: return True - + def public_key_auth_supported(self) -> bool: return True - + def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool: return True class TerminalSession: """Manages terminal state for an SSH session.""" - + def __init__(self, process: asyncssh.SSHServerProcess): self.process = process self.width = 80 self.height = 24 self._resize_event = asyncio.Event() self._update_size() - + def _update_size(self): """Update terminal size from process.""" try: @@ -69,28 +69,28 @@ class TerminalSession: logger.debug(f"Terminal size updated: {self.width}x{self.height}") except Exception as e: logger.warning(f"Could not get terminal size: {e}") - + def handle_resize(self, width, height, pixwidth, pixheight): """Handle terminal resize event.""" logger.debug(f"Resize event: {width}x{height}") self.width = max(width, 40) self.height = max(height, 10) self._resize_event.set() - + def write(self, data: str): """Write string data to terminal.""" if isinstance(data, str): data = data.encode('utf-8') self.process.stdout.write(data) - + def clear(self): """Clear screen completely and move cursor home.""" # Reset scrolling region, clear entire screen, move to home self.write("\033[r\033[2J\033[3J\033[H") - + def hide_cursor(self): self.write("\033[?25l") - + def show_cursor(self): self.write("\033[?25h") @@ -98,13 +98,13 @@ class TerminalSession: async def handle_client(process: asyncssh.SSHServerProcess) -> None: """Handle an SSH client session.""" username = process.get_extra_info("username") or "guest" - + # Create terminal session session = TerminalSession(process) - + term_type = process.get_terminal_type() or "xterm-256color" logger.info(f"Client {username}: term={term_type}, size={session.width}x{session.height}") - + # Determine mode if username == "learn": mode = "tutorial" @@ -112,7 +112,7 @@ async def handle_client(process: asyncssh.SSHServerProcess) -> None: mode = "spectate" else: mode = "play" - + try: session.hide_cursor() await run_simple_menu(process, session, username, mode) @@ -139,7 +139,7 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode from rich.layout import Layout from rich.style import Style import io - + class ProcessWriter: def __init__(self, sess): self._session = sess @@ -147,27 +147,27 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode self._session.write(data) def flush(self): pass - + def render_menu(): """Render the main menu centered on screen.""" # Re-fetch terminal size before rendering session._update_size() - + writer = ProcessWriter(session) console = Console(file=writer, width=session.width, height=session.height, force_terminal=True, color_system="truecolor") - + session.clear() - + # Calculate vertical padding for centering menu_height = 22 top_pad = max(0, (session.height - menu_height) // 2) - + for _ in range(top_pad): console.print() - + # Chess piece decorations pieces = "♔ ♕ ♖ ♗ ♘ ♙" - + # Title with gradient effect if session.width >= 50: console.print(Align.center(Text(pieces, style="dim white"))) @@ -178,9 +178,9 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode console.print(Align.center(Text(pieces[::-1], style="dim white"))) else: console.print(Align.center(Text("♟ SHELLMATE ♟", style="bold green"))) - + console.print() - + # Menu items as a table for better alignment menu_table = Table(show_header=False, box=None, padding=(0, 2)) menu_table.add_column(justify="center") @@ -192,7 +192,7 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode menu_table.add_row("[bright_white on red] q [/bright_white on red] Quit [dim]👋[/dim]") menu_table.add_row("") menu_table.add_row(Text("Press a key to select...", style="dim italic")) - + panel_width = min(45, session.width - 4) panel = Panel( Align.center(menu_table), @@ -202,25 +202,25 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode padding=(1, 2), ) console.print(Align.center(panel)) - + # Footer console.print() console.print(Align.center(Text(f"Terminal: {session.width}×{session.height}", style="dim"))) - + render_menu() - + # Wait for input while True: try: data = await process.stdin.read(1) if not data: break - + char = data.decode() if isinstance(data, bytes) else data - + # Update terminal size in case it changed session._update_size() - + if char in ('q', 'Q', '\x03', '\x04'): # q, Ctrl+C, Ctrl+D session.clear() session.write("\r\n\033[33mGoodbye! Thanks for playing!\033[0m\r\n\r\n") @@ -253,7 +253,7 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon from rich.align import Align from rich.text import Text from rich.box import ROUNDED - + class ProcessWriter: def __init__(self, sess): self._session = sess @@ -261,11 +261,11 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon self._session.write(data) def flush(self): pass - + board = chess.Board() move_history = [] status_msg = "" - + # Try to use Stockfish stockfish_engine = None try: @@ -274,22 +274,22 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon stockfish_engine.set_skill_level(5) # Medium difficulty except Exception as e: logger.warning(f"Stockfish not available: {e}") - + # Unicode pieces PIECES = { 'K': '♔', 'Q': '♕', 'R': '♖', 'B': '♗', 'N': '♘', 'P': '♙', 'k': '♚', 'q': '♛', 'r': '♜', 'b': '♝', 'n': '♞', 'p': '♟', } - + # Selection state for two-step moves selected_square = None # None or chess square int legal_targets = set() # Set of legal destination squares - + def get_cell_style(square, piece, is_light): """Get ANSI style for a cell based on selection state.""" is_selected = (square == selected_square) is_target = (square in legal_targets) - + # Background colors if is_selected: bg = "\033[48;5;33m" # Blue for selected @@ -299,28 +299,28 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon bg = "" # Default terminal else: bg = "\033[48;5;236m" # Dark grey - + bg_end = "\033[0m" if bg else "" return bg, bg_end - + def render_board(): """Large, terminal-filling chess board with selection highlighting.""" nonlocal status_msg session._update_size() session.clear() - + # Use compact board to fit most terminals lines = [] - + # Title lines.append("") lines.append("\033[1;32m♔ S H E L L M A T E ♔\033[0m") lines.append("") - + # Column labels lines.append(" A B C D E F G H") lines.append(" ╔═════╤═════╤═════╤═════╤═════╤═════╤═════╤═════╗") - + for rank in range(7, -1, -1): # Piece row piece_row = f" \033[1;36m{rank + 1}\033[0m ║" @@ -329,7 +329,7 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon piece = board.piece_at(square) is_light = (rank + file) % 2 == 1 bg, bg_end = get_cell_style(square, piece, is_light) - + if piece: char = PIECES.get(piece.symbol(), '?') if piece.color == chess.WHITE: @@ -341,68 +341,68 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon piece_row += f"{bg} \033[1;32m·\033[0m{bg} {bg_end}" else: piece_row += f"{bg} {bg_end}" - + if file < 7: piece_row += "│" piece_row += f"║ \033[1;36m{rank + 1}\033[0m" lines.append(piece_row) - + if rank > 0: lines.append(" ╟─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────╢") - + lines.append(" ╚═════╧═════╧═════╧═════╧═════╧═════╧═════╧═════╝") lines.append(" A B C D E F G H") board_width = 57 - + lines.append("") - + # Status if board.turn == chess.WHITE: lines.append(" \033[1;97mWhite ♔\033[0m to move") else: lines.append(" \033[1;33mBlack ♚\033[0m to move") - + if board.is_check(): lines.append(" \033[1;31m⚠ CHECK! ⚠\033[0m") - + if move_history: last_moves = move_history[-5:] lines.append(f" \033[90mRecent: {' '.join(last_moves)}\033[0m") - + if status_msg: lines.append(f" \033[33m{status_msg}\033[0m") status_msg = "" - + lines.append("") - + # Instructions based on state if selected_square is not None: sq_name = chess.square_name(selected_square).upper() lines.append(f" \033[36mSelected: {sq_name}\033[0m → Type destination (or ESC to cancel)") else: lines.append(" Type \033[36msquare\033[0m (e.g. E2) to select │ \033[31mQ\033[0m = quit") - + lines.append("") - + # Calculate centering total_height = len(lines) left_pad = max(0, (session.width - board_width) // 2) pad = " " * left_pad top_pad = max(0, (session.height - total_height) // 2) - + for _ in range(top_pad): session.write("\r\n") - + for line in lines: session.write(pad + line + "\r\n") - + # Input prompt if selected_square is not None: session.write(pad + f" \033[32m→ \033[0m") else: session.write(pad + f" \033[36m> \033[0m") session.show_cursor() - + def parse_square(text): """Parse a square name like 'e2' or 'E2' into a chess square.""" text = text.strip().lower() @@ -414,39 +414,39 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon file_idx = ord(file_char) - ord('a') rank_idx = int(rank_char) - 1 return chess.square(file_idx, rank_idx) - + def select_square(sq): """Select a square and compute legal moves from it.""" nonlocal selected_square, legal_targets selected_square = sq legal_targets = set() - + piece = board.piece_at(sq) if piece and piece.color == board.turn: # Find all legal moves from this square for move in board.legal_moves: if move.from_square == sq: legal_targets.add(move.to_square) - + def clear_selection(): """Clear the current selection.""" nonlocal selected_square, legal_targets selected_square = None legal_targets = set() - + render_board() - + input_buffer = "" - + while not board.is_game_over(): try: data = await process.stdin.read(1) if not data: break - + char = data.decode() if isinstance(data, bytes) else data session._update_size() - + # Quit if char.lower() == 'q' and not input_buffer: status_msg = "Thanks for playing!" @@ -454,7 +454,7 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon render_board() await asyncio.sleep(1) break - + # Escape - cancel selection if char == '\x1b': if selected_square is not None: @@ -462,24 +462,24 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon render_board() input_buffer = "" continue - + # Ctrl+C/D if char in ('\x03', '\x04'): break - + # Backspace if char == '\x7f' or char == '\b': if input_buffer: input_buffer = input_buffer[:-1] session.write('\b \b') continue - + # Enter - process input if char in ('\r', '\n'): if input_buffer: sq = parse_square(input_buffer) input_buffer = "" - + if sq is not None: if selected_square is None: # First click - select piece @@ -500,17 +500,17 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon if (piece.color == chess.WHITE and chess.square_rank(sq) == 7) or \ (piece.color == chess.BLACK and chess.square_rank(sq) == 0): move = chess.Move(selected_square, sq, promotion=chess.QUEEN) - + board.push(move) move_history.append(move.uci()) clear_selection() render_board() - + # AI response if opponent == "ai" and not board.is_game_over(): session.hide_cursor() session.write("\r\n \033[36mAI thinking...\033[0m") - + if stockfish_engine: try: stockfish_engine.set_fen_position(board.fen()) @@ -523,7 +523,7 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon import random await asyncio.sleep(0.5) ai_move = random.choice(list(board.legal_moves)) - + board.push(ai_move) move_history.append(ai_move.uci()) render_board() @@ -545,23 +545,23 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon status_msg = "Invalid square (use a1-h8)" render_board() continue - + # Regular character input if char.isprintable() and len(input_buffer) < 2: input_buffer += char session.write(char.upper()) - + except asyncio.CancelledError: break except Exception as e: logger.error(f"Game input error: {e}") continue - + if board.is_game_over(): session.clear() writer = ProcessWriter(session) console = Console(file=writer, width=session.width, height=session.height, force_terminal=True) - + console.print() if board.is_checkmate(): winner = "Black ♚" if board.turn == chess.WHITE else "White ♔" @@ -585,19 +585,19 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon border_style="yellow", width=40 ))) - + await asyncio.sleep(3) def ensure_host_key(key_path: str) -> None: """Generate SSH host key if it doesn't exist.""" import subprocess - + if not os.path.exists(key_path): logger.info(f"Generating SSH host key at {key_path}") os.makedirs(os.path.dirname(key_path), exist_ok=True) subprocess.run([ - "ssh-keygen", "-t", "ed25519", + "ssh-keygen", "-t", "ed25519", "-f", key_path, "-N", "" ], check=True) logger.info("SSH host key generated") @@ -611,13 +611,13 @@ async def start_server( """Start the SSH server.""" port = port or int(os.environ.get("SHELLMATE_SSH_PORT", "2222")) host_keys = host_keys or ["/etc/shellmate/ssh_host_key"] - + # Ensure host key exists (generate if needed) for key_path in host_keys: ensure_host_key(key_path) - + logger.info(f"Starting ShellMate SSH server on {host}:{port}") - + server = await asyncssh.create_server( ShellMateSSHServer, host, @@ -626,7 +626,7 @@ async def start_server( process_factory=handle_client, encoding=None, # Binary mode ) - + return server @@ -636,10 +636,10 @@ def main() -> None: level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) - + loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - + try: loop.run_until_complete(start_server()) loop.run_forever() diff --git a/src/shellmate/tui/app.py b/src/shellmate/tui/app.py index 63b27de..7b266d4 100644 --- a/src/shellmate/tui/app.py +++ b/src/shellmate/tui/app.py @@ -20,12 +20,12 @@ from shellmate.tui.widgets.move_input import parse_move class MenuScreen(Screen): """Main menu screen.""" - + def compose(self) -> ComposeResult: yield Header() yield MainMenu() yield Footer() - + def on_main_menu_mode_selected(self, event: MainMenu.ModeSelected) -> None: """Handle game mode selection.""" if event.mode == "vs_ai": @@ -40,7 +40,7 @@ class MenuScreen(Screen): class GameScreen(Screen): """Main game screen.""" - + BINDINGS = [ Binding("escape", "back", "Back to Menu"), Binding("f", "flip", "Flip Board"), @@ -48,7 +48,7 @@ class GameScreen(Screen): Binding("u", "undo", "Undo"), Binding("n", "new_game", "New Game"), ] - + DEFAULT_CSS = """ GameScreen { layout: grid; @@ -57,7 +57,7 @@ class GameScreen(Screen): grid-rows: 1fr; padding: 0; } - + #board-container { align: center middle; padding: 0; @@ -66,36 +66,36 @@ class GameScreen(Screen): min-width: 40; min-height: 22; } - + #chess-board { width: auto; height: auto; min-width: 36; min-height: 20; } - + #sidebar { padding: 1; min-width: 20; max-width: 30; border-left: solid #2a3a2a; } - + #game-status { height: auto; max-height: 8; } - + #move-list { height: 1fr; min-height: 8; } - + #move-input { dock: bottom; height: auto; } - + .hint-text { padding: 1; background: #1a3a1a; @@ -103,38 +103,38 @@ class GameScreen(Screen): margin: 1 0; } """ - + def __init__(self, opponent: str = "ai", **kwargs): super().__init__(**kwargs) self.opponent = opponent self.board = chess.Board() self._hint_text: str | None = None self._ai_engine = None - + def compose(self) -> ComposeResult: yield Header() - + with Horizontal(): with Container(id="board-container"): yield ChessBoardWidget(board=self.board, id="chess-board") - + with Vertical(id="sidebar"): yield GameStatusWidget(id="game-status") yield MoveListWidget(id="move-list") yield MoveInput(id="move-input") - + yield Footer() - + def on_mount(self) -> None: """Initialize game on mount.""" status = self.query_one("#game-status", GameStatusWidget) status.set_players("You", "Stockfish" if self.opponent == "ai" else "Opponent") status.update_from_board(self.board) - + # Initialize AI if playing against computer if self.opponent == "ai": self._init_ai() - + def _init_ai(self) -> None: """Initialize AI engine.""" try: @@ -142,25 +142,25 @@ class GameScreen(Screen): self._ai_engine = ChessAI(skill_level=10) except Exception as e: self.notify(f"AI unavailable: {e}", severity="warning") - + def on_move_input_move_submitted(self, event: MoveInput.MoveSubmitted) -> None: """Handle move submission.""" move = parse_move(self.board, event.move) - + if move is None: self.notify(f"Invalid move: {event.move}", severity="error") return - + self._make_move(move) - + # AI response if self.opponent == "ai" and not self.board.is_game_over() and self._ai_engine: self._ai_move() - + def on_move_input_command_submitted(self, event: MoveInput.CommandSubmitted) -> None: """Handle command submission.""" cmd = event.command.lower() - + if cmd == "hint": self.action_hint() elif cmd == "resign": @@ -174,27 +174,27 @@ class GameScreen(Screen): self.action_new_game() else: self.notify(f"Unknown command: /{cmd}", severity="warning") - + def _make_move(self, move: chess.Move) -> None: """Execute a move on the board.""" # Get SAN before pushing san = self.board.san(move) - + # Make the move self.board.push(move) - + # Update widgets board_widget = self.query_one("#chess-board", ChessBoardWidget) board_widget.set_board(self.board) board_widget.set_last_move(move) board_widget.select_square(None) - + move_list = self.query_one("#move-list", MoveListWidget) move_list.add_move(san) - + status = self.query_one("#game-status", GameStatusWidget) status.update_from_board(self.board) - + # Check game end if self.board.is_checkmate(): winner = "Black" if self.board.turn == chess.WHITE else "White" @@ -203,36 +203,36 @@ class GameScreen(Screen): self.notify("Stalemate! Game drawn.", severity="information") elif self.board.is_check(): self.notify("Check!", severity="warning") - + def _ai_move(self) -> None: """Make AI move.""" if not self._ai_engine: return - + try: best_move_uci = self._ai_engine.get_best_move(self.board.fen()) move = chess.Move.from_uci(best_move_uci) - + # Small delay for UX self.set_timer(0.5, lambda: self._make_move(move)) - + # Update evaluation analysis = self._ai_engine.analyze_position(self.board.fen(), depth=10) status = self.query_one("#game-status", GameStatusWidget) status.set_evaluation(analysis.evaluation) - + except Exception as e: self.notify(f"AI error: {e}", severity="error") - + def action_back(self) -> None: """Return to menu.""" self.app.pop_screen() - + def action_flip(self) -> None: """Flip the board.""" board_widget = self.query_one("#chess-board", ChessBoardWidget) board_widget.flip() - + def action_hint(self) -> None: """Get a hint.""" if self._ai_engine and self.board.turn == chess.WHITE: @@ -240,67 +240,67 @@ class GameScreen(Screen): self.notify(f"💡 {hint}") else: self.notify("Hints only available on your turn", severity="warning") - + def action_undo(self) -> None: """Undo the last move (or last two if vs AI).""" if len(self.board.move_stack) == 0: self.notify("No moves to undo", severity="warning") return - + # Undo player move self.board.pop() move_list = self.query_one("#move-list", MoveListWidget) move_list.undo() - + # Also undo AI move if applicable if self.opponent == "ai" and len(self.board.move_stack) > 0: self.board.pop() move_list.undo() - + # Update display board_widget = self.query_one("#chess-board", ChessBoardWidget) board_widget.set_board(self.board) board_widget.set_last_move(None) - + status = self.query_one("#game-status", GameStatusWidget) status.update_from_board(self.board) - + self.notify("Move undone") - + def action_new_game(self) -> None: """Start a new game.""" self.board = chess.Board() - + board_widget = self.query_one("#chess-board", ChessBoardWidget) board_widget.set_board(self.board) board_widget.set_last_move(None) - + move_list = self.query_one("#move-list", MoveListWidget) move_list.clear() - + status = self.query_one("#game-status", GameStatusWidget) status.update_from_board(self.board) status.set_evaluation(None) - + self.notify("New game started!") class ShellMateApp(App): """ShellMate TUI Chess Application.""" - + TITLE = "ShellMate" SUB_TITLE = "SSH into Chess Mastery" - + CSS = """ Screen { background: #0a0a0a; } """ - + BINDINGS = [ Binding("q", "quit", "Quit"), ] - + def __init__( self, username: str = "guest", @@ -309,7 +309,7 @@ class ShellMateApp(App): super().__init__() self.username = username self.initial_mode = mode - + def on_mount(self) -> None: """Start with menu screen.""" self.push_screen(MenuScreen()) diff --git a/src/shellmate/tui/widgets/__init__.py b/src/shellmate/tui/widgets/__init__.py index 1044efe..149a017 100644 --- a/src/shellmate/tui/widgets/__init__.py +++ b/src/shellmate/tui/widgets/__init__.py @@ -8,7 +8,7 @@ from .menu import MainMenu __all__ = [ "ChessBoardWidget", - "MoveInput", + "MoveInput", "MoveListWidget", "GameStatusWidget", "MainMenu", diff --git a/src/shellmate/tui/widgets/board.py b/src/shellmate/tui/widgets/board.py index ece4306..ad38569 100644 --- a/src/shellmate/tui/widgets/board.py +++ b/src/shellmate/tui/widgets/board.py @@ -48,7 +48,7 @@ BOX = { class ChessBoardWidget(Widget): """Interactive chess board widget with auto-sizing.""" - + DEFAULT_CSS = """ ChessBoardWidget { width: 100%; @@ -58,7 +58,7 @@ class ChessBoardWidget(Widget): padding: 0; } """ - + # Reactive properties selected_square: reactive[int | None] = reactive(None) legal_moves: reactive[set] = reactive(set) @@ -66,7 +66,7 @@ class ChessBoardWidget(Widget): flipped: reactive[bool] = reactive(False) use_heavy_border: reactive[bool] = reactive(True) compact_mode: reactive[bool] = reactive(False) - + def __init__( self, board: chess.Board | None = None, @@ -76,83 +76,83 @@ class ChessBoardWidget(Widget): self.board = board or chess.Board() self._cell_width = 3 # Will be calculated self._cell_height = 1 - + def get_content_width(self, container: Size, viewport: Size) -> int: """Calculate optimal width.""" # Each cell is at least 3 chars wide + borders + labels min_width = 8 * 3 + 9 + 4 # 8 cells * 3 + 9 borders + labels return max(min_width, container.width) - + def get_content_height(self, container: Size, viewport: Size) -> int: """Calculate optimal height.""" # Each cell is 1-2 lines + borders + labels return max(18, min(22, container.height)) - + def _calculate_cell_size(self, width: int, height: int) -> tuple[int, int]: """Calculate cell dimensions based on available space.""" # Available width for cells (minus borders and labels) available_width = width - 4 # 2 for labels on each side available_height = height - 4 # 2 for labels + 2 for borders - + # Calculate cell width (must be odd for centering) cell_w = max(3, (available_width - 9) // 8) # -9 for internal borders if cell_w % 2 == 0: cell_w -= 1 cell_w = min(cell_w, 7) # Cap at 7 for sanity - + # Calculate cell height cell_h = max(1, (available_height - 9) // 8) cell_h = min(cell_h, 3) - + return cell_w, cell_h - + def render(self) -> RenderableType: """Render the chess board with perfect alignment.""" size = self.size self._cell_width, self._cell_height = self._calculate_cell_size(size.width, size.height) - + # Use compact mode for small terminals if size.width < 40 or size.height < 20: return self._render_compact() else: return self._render_standard() - + def _render_compact(self) -> RenderableType: """Compact board for small terminals.""" text = Text() cw = 3 # Fixed cell width for compact - + files = "abcdefgh" if not self.flipped else "hgfedcba" - + # File labels - centered over cells text.append(" ", style=f"dim {LABEL_COLOR}") for f in files: text.append(f" {f} ", style=f"dim {LABEL_COLOR}") text.append("\n") - + # Top border border_style = Style(color=BORDER_COLOR) text.append(" ╔", style=border_style) text.append("═══╤" * 7 + "═══╗\n", style=border_style) - + ranks = range(7, -1, -1) if not self.flipped else range(8) - + for rank_idx, rank in enumerate(ranks): # Rank label text.append(f"{rank + 1}║", style=f"dim {LABEL_COLOR}") - + file_range = range(8) if not self.flipped else range(7, -1, -1) - + for file_idx, file in enumerate(file_range): square = chess.square(file, rank) piece = self.board.piece_at(square) - + # Determine square styling is_light = (rank + file) % 2 == 1 is_selected = square == self.selected_square is_legal_target = square in self.legal_moves is_last_move = self.last_move and square in self.last_move - + if is_selected: bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED elif is_last_move: @@ -161,7 +161,7 @@ class ChessBoardWidget(Widget): bg = HIGHLIGHT_LEGAL else: bg = LIGHT_SQUARE if is_light else DARK_SQUARE - + # Piece or empty if piece: char = PIECES_OUTLINE.get(piece.symbol(), '?') @@ -172,55 +172,55 @@ class ChessBoardWidget(Widget): else: char = ' ' fg = WHITE_PIECE - + style = Style(color=fg, bgcolor=bg, bold=True) text.append(f" {char} ", style=style) - + # Cell separator if file_idx < 7: text.append("│", style=border_style) - + text.append(f"║{rank + 1}\n", style=f"dim {LABEL_COLOR}") - + # Row separator if rank_idx < 7: text.append(" ╟", style=border_style) text.append("───┼" * 7 + "───╢\n", style=border_style) - + # Bottom border text.append(" ╚", style=border_style) text.append("═══╧" * 7 + "═══╝\n", style=border_style) - + # File labels text.append(" ", style=f"dim {LABEL_COLOR}") for f in files: text.append(f" {f} ", style=f"dim {LABEL_COLOR}") - + return text - + def _render_standard(self) -> RenderableType: """Standard board with variable cell size.""" text = Text() cw = self._cell_width ch = self._cell_height - + # Ensure odd width for centering if cw % 2 == 0: cw = max(3, cw - 1) - + files = "abcdefgh" if not self.flipped else "hgfedcba" pad = cw // 2 - + border_style = Style(color=BORDER_COLOR) label_style = Style(color=LABEL_COLOR, dim=True) - + # File labels - perfectly centered text.append(" ", style=label_style) for f in files: text.append(" " * pad + f + " " * pad, style=label_style) text.append(" ", style=label_style) # For border space text.append("\n") - + # Top border with double lines cell_border = BOX['H'] * cw text.append(" " + BOX['TL'], style=border_style) @@ -229,34 +229,34 @@ class ChessBoardWidget(Widget): if i < 7: text.append(BOX['TJ'], style=border_style) text.append(BOX['TR'] + "\n", style=border_style) - + ranks = range(7, -1, -1) if not self.flipped else range(8) - + for rank_idx, rank in enumerate(ranks): # Multi-line cells for cell_line in range(ch): is_middle = cell_line == ch // 2 - + # Rank label (only on middle line) if is_middle: text.append(f"{rank + 1}", style=label_style) else: text.append(" ", style=label_style) - + text.append(BOX['V'], style=border_style) - + file_range = range(8) if not self.flipped else range(7, -1, -1) - + for file_idx, file in enumerate(file_range): square = chess.square(file, rank) piece = self.board.piece_at(square) - + # Square styling is_light = (rank + file) % 2 == 1 is_selected = square == self.selected_square is_legal_target = square in self.legal_moves is_last_move = self.last_move and square in self.last_move - + if is_selected: bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED elif is_last_move: @@ -265,7 +265,7 @@ class ChessBoardWidget(Widget): bg = HIGHLIGHT_LEGAL else: bg = LIGHT_SQUARE if is_light else DARK_SQUARE - + # Content (only on middle line) if is_middle: if piece: @@ -280,21 +280,21 @@ class ChessBoardWidget(Widget): else: char = ' ' fg = WHITE_PIECE - + style = Style(color=fg, bgcolor=bg, bold=True) cell_content = " " * pad + char + " " * pad text.append(cell_content, style=style) - + # Separator if file_idx < 7: text.append(BOX['v'], style=border_style) - + # Right border and rank label if is_middle: text.append(f"{BOX['V']}{rank + 1}\n", style=label_style) else: text.append(f"{BOX['V']} \n", style=border_style) - + # Row separator if rank_idx < 7: text.append(" " + BOX['LJ'], style=border_style) @@ -303,7 +303,7 @@ class ChessBoardWidget(Widget): if i < 7: text.append(BOX['x'], style=border_style) text.append(BOX['RJ'] + "\n", style=border_style) - + # Bottom border text.append(" " + BOX['BL'], style=border_style) for i in range(8): @@ -311,33 +311,33 @@ class ChessBoardWidget(Widget): if i < 7: text.append(BOX['BJ'], style=border_style) text.append(BOX['BR'] + "\n", style=border_style) - + # File labels text.append(" ", style=label_style) for f in files: text.append(" " * pad + f + " " * pad, style=label_style) text.append(" ", style=label_style) - + return text - + def set_board(self, board: chess.Board) -> None: """Update the board state.""" self.board = board self.refresh() - + def select_square(self, square: int | None) -> None: """Select a square and show legal moves from it.""" self.selected_square = square if square is not None: self.legal_moves = { - move.to_square - for move in self.board.legal_moves + move.to_square + for move in self.board.legal_moves if move.from_square == square } else: self.legal_moves = set() self.refresh() - + def set_last_move(self, move: chess.Move | None) -> None: """Highlight the last move played.""" if move: @@ -345,12 +345,12 @@ class ChessBoardWidget(Widget): else: self.last_move = None self.refresh() - + def flip(self) -> None: """Flip the board perspective.""" self.flipped = not self.flipped self.refresh() - + def square_from_coords(self, file: str, rank: str) -> int | None: """Convert algebraic notation to square index.""" try: diff --git a/src/shellmate/tui/widgets/menu.py b/src/shellmate/tui/widgets/menu.py index 6474f86..3d8cfbc 100644 --- a/src/shellmate/tui/widgets/menu.py +++ b/src/shellmate/tui/widgets/menu.py @@ -10,14 +10,14 @@ from rich.console import RenderableType class MainMenu(Widget): """Main menu for game mode selection.""" - + DEFAULT_CSS = """ MainMenu { width: 100%; height: 100%; align: center middle; } - + MainMenu > Vertical { width: 50; height: auto; @@ -25,37 +25,37 @@ class MainMenu(Widget): border: round #333; background: #111; } - + MainMenu .title { text-align: center; padding: 1 0 2 0; } - + MainMenu Button { width: 100%; margin: 1 0; } - + MainMenu .subtitle { text-align: center; color: #666; padding: 1 0; } """ - + class ModeSelected(Message): """Emitted when a game mode is selected.""" def __init__(self, mode: str) -> None: super().__init__() self.mode = mode - + LOGO = """ ┌─────────────────────────────┐ │ ♟️ S H E L L M A T E │ │ SSH into Chess Mastery │ └─────────────────────────────┘ """ - + def compose(self): with Vertical(): yield Static(self.LOGO, classes="title") @@ -65,7 +65,7 @@ class MainMenu(Widget): yield Button("📚 Learn Chess", id="btn-learn", variant="default") yield Button("👀 Spectate", id="btn-watch", variant="default") yield Static("Press Q to quit", classes="subtitle") - + def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button press.""" mode_map = { diff --git a/src/shellmate/tui/widgets/move_input.py b/src/shellmate/tui/widgets/move_input.py index 938589f..cf7ac5e 100644 --- a/src/shellmate/tui/widgets/move_input.py +++ b/src/shellmate/tui/widgets/move_input.py @@ -7,7 +7,7 @@ import chess class MoveInput(Input): """Input widget for chess moves.""" - + DEFAULT_CSS = """ MoveInput { dock: bottom; @@ -15,46 +15,46 @@ class MoveInput(Input): margin: 1 0; } """ - + class MoveSubmitted(Message): """Message sent when a move is submitted.""" def __init__(self, move: str) -> None: super().__init__() self.move = move - + class CommandSubmitted(Message): """Message sent when a command is submitted.""" def __init__(self, command: str) -> None: super().__init__() self.command = command - + def __init__(self, **kwargs): super().__init__( placeholder="Enter move (e.g., e4, Nf3, O-O) or command (/hint, /resign)", **kwargs ) - + def on_input_submitted(self, event: Input.Submitted) -> None: """Handle input submission.""" value = event.value.strip() - + if not value: return - + if value.startswith('/'): # It's a command self.post_message(self.CommandSubmitted(value[1:])) else: # It's a move self.post_message(self.MoveSubmitted(value)) - + self.value = "" def parse_move(board: chess.Board, move_str: str) -> chess.Move | None: """Parse a move string into a chess.Move object.""" move_str = move_str.strip() - + # Try UCI format first (e2e4) try: move = chess.Move.from_uci(move_str) @@ -62,7 +62,7 @@ def parse_move(board: chess.Board, move_str: str) -> chess.Move | None: return move except ValueError: pass - + # Try SAN format (e4, Nf3, O-O) try: move = board.parse_san(move_str) @@ -70,5 +70,5 @@ def parse_move(board: chess.Board, move_str: str) -> chess.Move | None: return move except ValueError: pass - + return None diff --git a/src/shellmate/tui/widgets/move_list.py b/src/shellmate/tui/widgets/move_list.py index e5965ab..ac328e4 100644 --- a/src/shellmate/tui/widgets/move_list.py +++ b/src/shellmate/tui/widgets/move_list.py @@ -9,7 +9,7 @@ import chess class MoveListWidget(Widget): """Display the move history in standard notation.""" - + DEFAULT_CSS = """ MoveListWidget { width: 100%; @@ -19,47 +19,47 @@ class MoveListWidget(Widget): border: solid #333; } """ - + moves: reactive[list] = reactive(list, always_update=True) - + def __init__(self, **kwargs): super().__init__(**kwargs) self._moves: list[str] = [] - + def render(self) -> RenderableType: """Render the move list.""" text = Text() text.append("Move History\n", style="bold underline") text.append("─" * 20 + "\n", style="dim") - + if not self._moves: text.append("No moves yet", style="dim italic") return text - + # Display moves in pairs (white, black) for i in range(0, len(self._moves), 2): move_num = i // 2 + 1 white_move = self._moves[i] black_move = self._moves[i + 1] if i + 1 < len(self._moves) else "" - + text.append(f"{move_num:>3}. ", style="dim") text.append(f"{white_move:<8}", style="bold white") if black_move: text.append(f"{black_move:<8}", style="bold green") text.append("\n") - + return text - + def add_move(self, move_san: str) -> None: """Add a move to the history.""" self._moves.append(move_san) self.refresh() - + def clear(self) -> None: """Clear the move history.""" self._moves = [] self.refresh() - + def undo(self) -> str | None: """Remove and return the last move.""" if self._moves: @@ -67,7 +67,7 @@ class MoveListWidget(Widget): self.refresh() return move return None - + def get_pgn_moves(self) -> str: """Get moves in PGN format.""" result = [] diff --git a/src/shellmate/tui/widgets/status.py b/src/shellmate/tui/widgets/status.py index 902d8ac..812e3d2 100644 --- a/src/shellmate/tui/widgets/status.py +++ b/src/shellmate/tui/widgets/status.py @@ -10,7 +10,7 @@ import chess class GameStatusWidget(Widget): """Display current game status.""" - + DEFAULT_CSS = """ GameStatusWidget { width: 100%; @@ -19,7 +19,7 @@ class GameStatusWidget(Widget): border: solid #333; } """ - + def __init__(self, **kwargs): super().__init__(**kwargs) self._turn = chess.WHITE @@ -32,15 +32,15 @@ class GameStatusWidget(Widget): self._white_time: int | None = None # seconds self._black_time: int | None = None self._evaluation: float | None = None - + def render(self) -> RenderableType: """Render the status panel.""" text = Text() - + # Player indicator turn_indicator = "⚪" if self._turn == chess.WHITE else "⚫" turn_name = self._white_name if self._turn == chess.WHITE else self._black_name - + if self._is_checkmate: winner = self._black_name if self._turn == chess.WHITE else self._white_name text.append(f"♚ CHECKMATE!\n", style="bold red") @@ -55,20 +55,20 @@ class GameStatusWidget(Widget): text.append(f"{turn_indicator} {turn_name}'s turn\n", style="bold") if self._is_check: text.append("⚠ CHECK!\n", style="bold red") - + # Time controls (if set) if self._white_time is not None or self._black_time is not None: text.append("\n") white_time_str = self._format_time(self._white_time) black_time_str = self._format_time(self._black_time) - + white_style = "bold" if self._turn == chess.WHITE else "dim" black_style = "bold" if self._turn == chess.BLACK else "dim" - + text.append(f"⚪ {white_time_str}", style=white_style) text.append(" │ ", style="dim") text.append(f"⚫ {black_time_str}", style=black_style) - + # Evaluation (if available) if self._evaluation is not None and not (self._is_checkmate or self._is_stalemate or self._is_draw): text.append("\n\n") @@ -76,9 +76,9 @@ class GameStatusWidget(Widget): bar = self._eval_bar(self._evaluation) text.append(f"Eval: {eval_str}\n", style="dim") text.append(bar) - + return text - + def _format_time(self, seconds: int | None) -> str: """Format seconds as MM:SS.""" if seconds is None: @@ -86,24 +86,24 @@ class GameStatusWidget(Widget): mins = seconds // 60 secs = seconds % 60 return f"{mins:02d}:{secs:02d}" - + def _eval_bar(self, evaluation: float) -> Text: """Create a visual evaluation bar.""" text = Text() bar_width = 20 - + # Clamp evaluation to -5 to +5 for display clamped = max(-5, min(5, evaluation)) # Convert to 0-1 scale (0.5 = equal) normalized = (clamped + 5) / 10 white_chars = int(normalized * bar_width) black_chars = bar_width - white_chars - + text.append("█" * white_chars, style="white") text.append("█" * black_chars, style="green") - + return text - + def update_from_board(self, board: chess.Board) -> None: """Update status from a board state.""" self._turn = board.turn @@ -112,19 +112,19 @@ class GameStatusWidget(Widget): self._is_stalemate = board.is_stalemate() self._is_draw = board.is_insufficient_material() or board.can_claim_draw() self.refresh() - + def set_players(self, white: str, black: str) -> None: """Set player names.""" self._white_name = white self._black_name = black self.refresh() - + def set_time(self, white_seconds: int | None, black_seconds: int | None) -> None: """Set time remaining.""" self._white_time = white_seconds self._black_time = black_seconds self.refresh() - + def set_evaluation(self, evaluation: float | None) -> None: """Set position evaluation.""" self._evaluation = evaluation diff --git a/tests/test_ssh_server.py b/tests/test_ssh_server.py index 5640639..afec699 100644 --- a/tests/test_ssh_server.py +++ b/tests/test_ssh_server.py @@ -7,46 +7,46 @@ from unittest.mock import AsyncMock, MagicMock, patch class TestShellMateSSHServer: """Test SSH server authentication.""" - + def test_begin_auth_returns_false(self): """Auth should complete immediately (no auth required).""" from shellmate.ssh.server import ShellMateSSHServer - + server = ShellMateSSHServer() result = server.begin_auth("anyuser") - + assert result is False, "begin_auth should return False for no-auth" - + def test_password_auth_accepts_any(self): """Any password should be accepted.""" from shellmate.ssh.server import ShellMateSSHServer - + server = ShellMateSSHServer() - + assert server.password_auth_supported() is True assert server.validate_password("user", "") is True assert server.validate_password("user", "anypass") is True assert server.validate_password("guest", "password123") is True - + def test_pubkey_auth_accepts_any(self): """Any public key should be accepted.""" from shellmate.ssh.server import ShellMateSSHServer - + server = ShellMateSSHServer() mock_key = MagicMock() - + assert server.public_key_auth_supported() is True assert server.validate_public_key("user", mock_key) is True class TestModeSelection: """Test username-to-mode mapping.""" - + @pytest.mark.asyncio async def test_play_mode_default(self): """Default users should get play mode.""" from shellmate.ssh.server import handle_client - + process = MagicMock() process.get_extra_info = MagicMock(return_value="greg") process.get_terminal_type = MagicMock(return_value="xterm-256color") @@ -54,24 +54,24 @@ class TestModeSelection: process.stdin = AsyncMock() process.stdout = MagicMock() process.exit = MagicMock() - + # Mock stdin to return quit immediately process.stdin.read = AsyncMock(return_value=b'q') - + with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu: mock_menu.return_value = None await handle_client(process) - + # Verify mode was 'play' mock_menu.assert_called_once() call_args = mock_menu.call_args assert call_args[0][2] == "play" # mode argument - + @pytest.mark.asyncio async def test_learn_mode(self): """Username 'learn' should get tutorial mode.""" from shellmate.ssh.server import handle_client - + process = MagicMock() process.get_extra_info = MagicMock(return_value="learn") process.get_terminal_type = MagicMock(return_value="xterm") @@ -80,18 +80,18 @@ class TestModeSelection: process.stdout = MagicMock() process.exit = MagicMock() process.stdin.read = AsyncMock(return_value=b'q') - + with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu: await handle_client(process) - + call_args = mock_menu.call_args assert call_args[0][2] == "tutorial" - + @pytest.mark.asyncio async def test_watch_mode(self): """Username 'watch' should get spectate mode.""" from shellmate.ssh.server import handle_client - + process = MagicMock() process.get_extra_info = MagicMock(return_value="watch") process.get_terminal_type = MagicMock(return_value="xterm") @@ -100,100 +100,100 @@ class TestModeSelection: process.stdout = MagicMock() process.exit = MagicMock() process.stdin.read = AsyncMock(return_value=b'q') - + with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu: await handle_client(process) - + call_args = mock_menu.call_args assert call_args[0][2] == "spectate" class TestChessBoard: """Test chess board widget rendering.""" - + def test_board_initialization(self): """Board should initialize with standard position.""" from shellmate.tui.widgets.board import ChessBoardWidget import chess - + widget = ChessBoardWidget() - + assert widget.board.fen() == chess.STARTING_FEN assert widget.selected_square is None assert widget.flipped is False - + def test_board_flip(self): """Board flip should toggle perspective.""" from shellmate.tui.widgets.board import ChessBoardWidget - + widget = ChessBoardWidget() - + assert widget.flipped is False widget.flip() assert widget.flipped is True widget.flip() assert widget.flipped is False - + def test_square_selection(self): """Selecting a square should show legal moves.""" from shellmate.tui.widgets.board import ChessBoardWidget import chess - + widget = ChessBoardWidget() - + # Select e2 pawn e2 = chess.E2 widget.select_square(e2) - + assert widget.selected_square == e2 assert chess.E3 in widget.legal_moves assert chess.E4 in widget.legal_moves assert len(widget.legal_moves) == 2 # e3 and e4 - + def test_square_deselection(self): """Deselecting should clear legal moves.""" from shellmate.tui.widgets.board import ChessBoardWidget import chess - + widget = ChessBoardWidget() widget.select_square(chess.E2) widget.select_square(None) - + assert widget.selected_square is None assert len(widget.legal_moves) == 0 class TestMoveValidation: """Test move parsing and validation.""" - + def test_valid_uci_move(self): """Valid UCI moves should be accepted.""" import chess - + board = chess.Board() move = chess.Move.from_uci("e2e4") - + assert move in board.legal_moves - + def test_invalid_uci_move(self): """Invalid UCI moves should be rejected.""" import chess - + board = chess.Board() move = chess.Move.from_uci("e2e5") # Invalid - pawn can't go there - + assert move not in board.legal_moves - + def test_algebraic_to_uci(self): """Test converting algebraic notation.""" import chess - + board = chess.Board() - + # e4 in algebraic move = board.parse_san("e4") assert move.uci() == "e2e4" - + # Nf3 in algebraic board.push_san("e4") board.push_san("e5") @@ -203,77 +203,77 @@ class TestMoveValidation: class TestGameState: """Test game state detection.""" - + def test_checkmate_detection(self): """Checkmate should be detected.""" import chess - + # Fool's mate board = chess.Board() board.push_san("f3") board.push_san("e5") board.push_san("g4") board.push_san("Qh4") - + assert board.is_checkmate() assert board.is_game_over() - + def test_stalemate_detection(self): """Stalemate should be detected.""" import chess - + # Set up a stalemate position board = chess.Board("k7/8/1K6/8/8/8/8/8 b - - 0 1") - + # This isn't stalemate, let's use a real one board = chess.Board("k7/8/8/8/8/8/1R6/K7 b - - 0 1") # Actually, let's just check the method exists assert hasattr(board, 'is_stalemate') - + def test_check_detection(self): """Check should be detected.""" import chess - + board = chess.Board() board.push_san("e4") board.push_san("e5") board.push_san("Qh5") board.push_san("Nc6") board.push_san("Qxf7") - + assert board.is_check() # Integration tests class TestIntegration: """Integration tests for full flow.""" - + @pytest.mark.asyncio async def test_server_starts(self): """Server should start without errors.""" from shellmate.ssh.server import start_server import tempfile import os - + # Create a temporary host key with tempfile.TemporaryDirectory() as tmpdir: key_path = os.path.join(tmpdir, "test_key") - + # Generate a key import subprocess subprocess.run([ "ssh-keygen", "-t", "ed25519", "-f", key_path, "-N", "" ], check=True, capture_output=True) - + # Start server on a random port server = await start_server( host="127.0.0.1", port=0, # Random available port host_keys=[key_path], ) - + assert server is not None - + # Clean up server.close() await server.wait_closed() diff --git a/tests/test_ui_render.py b/tests/test_ui_render.py index 9a8b229..f6bc02b 100644 --- a/tests/test_ui_render.py +++ b/tests/test_ui_render.py @@ -15,12 +15,12 @@ def test_menu_render_no_markup_errors(): # Simulate the menu rendering code output = StringIO() console = Console(file=output, width=80, height=24, force_terminal=True, color_system="truecolor") - + username = "testuser" width = 80 height = 24 pieces = "♔ ♕ ♖ ♗ ♘ ♙" - + # Title section console.print(Align.center(Text(pieces, style="dim white"))) console.print() @@ -29,7 +29,7 @@ def test_menu_render_no_markup_errors(): console.print(Align.center(Text("SSH into Chess Mastery", style="italic bright_black"))) console.print(Align.center(Text(pieces[::-1], style="dim white"))) console.print() - + # Menu table - this is where markup errors often occur menu_table = Table(show_header=False, box=None, padding=(0, 2)) menu_table.add_column(justify="center") @@ -41,7 +41,7 @@ def test_menu_render_no_markup_errors(): menu_table.add_row("[bright_white on red] q [/bright_white on red] Quit [dim]👋[/dim]") menu_table.add_row("") menu_table.add_row(Text("Press a key to select...", style="dim italic")) - + panel_width = min(45, width - 4) panel = Panel( Align.center(menu_table), @@ -51,11 +51,11 @@ def test_menu_render_no_markup_errors(): padding=(1, 2), ) console.print(Align.center(panel)) - + # Footer console.print() console.print(Align.center(Text(f"Terminal: {width}×{height}", style="dim"))) - + # If we got here without exception, markup is valid rendered = output.getvalue() # Check key content is present (content may have ANSI codes) @@ -68,13 +68,13 @@ def test_game_status_render(): """Test that game status panel renders correctly.""" output = StringIO() console = Console(file=output, width=80, height=24, force_terminal=True) - + status_lines = [] status_lines.append("[bold white]White ♔ to move[/bold white]") status_lines.append("[dim]Moves: e2e4 e7e5 g1f3[/dim]") status_lines.append("") status_lines.append("[dim]Enter move (e.g. e2e4) │ [q]uit │ [r]esign[/dim]") - + panel = Panel( "\n".join(status_lines), box=ROUNDED, @@ -83,7 +83,7 @@ def test_game_status_render(): title="[bold]Game Status[/bold]" ) console.print(panel) - + rendered = output.getvalue() assert "White" in rendered assert "Game Status" in rendered @@ -92,17 +92,17 @@ def test_game_status_render(): def test_chess_board_render(): """Test that chess board renders without errors.""" output = StringIO() - + PIECES = { 'K': '♔', 'Q': '♕', 'R': '♖', 'B': '♗', 'N': '♘', 'P': '♙', 'k': '♚', 'q': '♛', 'r': '♜', 'b': '♝', 'n': '♞', 'p': '♟', } - + # Simplified board rendering (plain text) lines = [] lines.append(" a b c d e f g h") lines.append(" +---+---+---+---+---+---+---+---+") - + # Render rank 8 with pieces pieces_row = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r'] row = " 8 |" @@ -112,10 +112,10 @@ def test_chess_board_render(): row += " 8" lines.append(row) lines.append(" +---+---+---+---+---+---+---+---+") - + for line in lines: output.write(line + "\n") - + rendered = output.getvalue() assert "a b c" in rendered assert "♜" in rendered # Black rook @@ -126,19 +126,19 @@ def test_narrow_terminal_render(): """Test that menu renders correctly on narrow terminals.""" output = StringIO() console = Console(file=output, width=40, height=20, force_terminal=True) - + # Small terminal fallback console.print(Align.center(Text("♟ SHELLMATE ♟", style="bold green"))) console.print() - + menu_table = Table(show_header=False, box=None) menu_table.add_column(justify="center") menu_table.add_row(Text("Welcome!", style="cyan")) menu_table.add_row("[dim]1. Play[/dim]") menu_table.add_row("[dim]q. Quit[/dim]") - + console.print(Align.center(menu_table)) - + rendered = output.getvalue() assert "SHELLMATE" in rendered @@ -147,7 +147,7 @@ def test_markup_escape_special_chars(): """Test that usernames with special chars don't break markup.""" output = StringIO() console = Console(file=output, width=80, height=24, force_terminal=True) - + # Usernames that could break markup test_usernames = [ "normal_user", @@ -156,11 +156,11 @@ def test_markup_escape_special_chars(): "[admin]", "test/path", ] - + for username in test_usernames: # Using Text() object safely escapes special characters console.print(Text(f"Welcome, {username}!", style="cyan")) - + rendered = output.getvalue() assert "normal_user" in rendered # Text() should have escaped the brackets safely