From 7ec1f948aaa55af42c7f48f7f2ee05f3100d49e8 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 4 Jun 2026 18:34:57 +0000 Subject: [PATCH] fabro(01KT9X0N42W6PRBVH2N5914EQJ): impl_ui (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KT9X0N42W6PRBVH2N5914EQJ Fabro-Completed: 12 Fabro-Checkpoint: 75d4b4937b2d9de19e391d01c4418364cc52e71d ⚒️ Generated with [Fabro](https://fabro.sh) --- solitaire-app/main.py | 4 +- solitaire-app/src/solitaire_tui/ui.py | 199 ++++++++++++++------------ solitaire-app/tests/test_game.py | 48 +++++++ status.json | 2 +- 4 files changed, 158 insertions(+), 95 deletions(-) diff --git a/solitaire-app/main.py b/solitaire-app/main.py index 9715c3a3c..0665eae16 100644 --- a/solitaire-app/main.py +++ b/solitaire-app/main.py @@ -40,8 +40,8 @@ def run_smoke_test() -> None: loader = unittest.TestLoader() # Discover and run tests try: - from tests.test_game import TestSolitaireGame - suite = loader.loadTestsFromTestCase(TestSolitaireGame) + import tests.test_game as test_game + suite = loader.loadTestsFromModule(test_game) except Exception as e: print(f"✗ Failed to import tests: {e}") sys.exit(1) diff --git a/solitaire-app/src/solitaire_tui/ui.py b/solitaire-app/src/solitaire_tui/ui.py index 6705819aa..c95557786 100644 --- a/solitaire-app/src/solitaire_tui/ui.py +++ b/solitaire-app/src/solitaire_tui/ui.py @@ -81,9 +81,13 @@ class SolitaireTUI: if self.cursor_zone == 'top': if self.cursor_col == 0: # Draw from Stock to Waste + was_empty_stock = not self.game.stock success = self.game.draw() if success: - self.status_msg = "Drawn card." + if was_empty_stock: + self.status_msg = "Recycled waste to stock." + else: + self.status_msg = "Drawn card." else: self.status_msg = "Stock and Waste are empty." self.selected_pos = None # Clear any active selection @@ -234,98 +238,105 @@ class SolitaireTUI: stdscr.addstr(y, x + 4, right_br, attr) def draw_screen(self, stdscr) -> None: - stdscr.erase() - h, w = stdscr.getmaxyx() - - # Check window size - if h < 20 or w < 60: - stdscr.addstr(0, 0, "Terminal too small. Please enlarge to at least 80x24.", curses.color_pair(1)) + try: + stdscr.erase() + h, w = stdscr.getmaxyx() + + # Check window size + if h < 20 or w < 60: + try: + stdscr.addstr(0, 0, "Terminal too small. Please enlarge to at least 80x24.", curses.color_pair(1)) + except curses.error: + pass + stdscr.refresh() + return + + # Title / Help Banner + stdscr.addstr(0, 2, "KLONDIKE SOLITAIRE", curses.color_pair(4) | curses.A_BOLD) + help_text = "Arrows/Vim:Move | Space/Enter:Select/Draw | A:Auto-Move | U:Undo | R:Restart | Q:Quit" + stdscr.addstr(1, 2, help_text[:w-3], curses.color_pair(3)) + + # Top row elements positioning + # Stock (col 0), Waste (col 1), gap (col 2), Foundations 0-3 (cols 3-6) + x_coords = [2 + i * 8 for i in range(7)] + + # --- Draw STOCK --- + is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 0) + is_selected = False # Stock can never be selected + stdscr.addstr(3, x_coords[0], "STOCK", curses.color_pair(3)) + + if self.game.stock: + # Top card of Stock is face down + self.draw_card_representation(stdscr, 4, x_coords[0], Card('S', 1, False), is_cursor, is_selected) + else: + self.draw_card_representation(stdscr, 4, x_coords[0], None, is_cursor, is_selected) + + # --- Draw WASTE --- + is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 1) + is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == 1) + stdscr.addstr(3, x_coords[1], "WASTE", curses.color_pair(3)) + + if self.game.waste: + self.draw_card_representation(stdscr, 4, x_coords[1], self.game.waste[-1], is_cursor, is_selected) + else: + self.draw_card_representation(stdscr, 4, x_coords[1], None, is_cursor, is_selected) + + # --- Draw FOUNDATIONS --- + for i in range(4): + col_idx = 3 + i + is_cursor = (self.cursor_zone == 'top' and self.cursor_col == col_idx) + is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == col_idx) + stdscr.addstr(3, x_coords[col_idx], f"FOUND {i+1}", curses.color_pair(3)) + + pile = self.game.foundations[i] + if pile: + self.draw_card_representation(stdscr, 4, x_coords[col_idx], pile[-1], is_cursor, is_selected) + else: + self.draw_card_representation(stdscr, 4, x_coords[col_idx], None, is_cursor, is_selected) + + # --- Draw TABLEAU --- + stdscr.addstr(6, 2, "TABLEAU COLUMNS:", curses.color_pair(4)) + for col_idx in range(7): + pile = self.game.tableau[col_idx] + x = x_coords[col_idx] + + # Label + stdscr.addstr(7, x, f"COL {col_idx+1}", curses.color_pair(3)) + + if not pile: + is_cursor = (self.cursor_zone == 'bottom' and self.cursor_col == col_idx) + is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'bottom' and self.selected_pos[1] == col_idx) + self.draw_card_representation(stdscr, 8, x, None, is_cursor, is_selected) + else: + for card_idx, card in enumerate(pile): + y = 8 + card_idx + # Check cursor highlighting + is_cursor = ( + self.cursor_zone == 'bottom' and + self.cursor_col == col_idx and + self.cursor_card_idx == card_idx + ) + # Check selection highlighting + # If this column is selected as source, we highlight the selected card and all cards below it! + is_selected = False + if self.selected_pos is not None: + src_zone, src_col, src_card_idx = self.selected_pos + if src_zone == 'bottom' and src_col == col_idx and card_idx >= src_card_idx: + is_selected = True + + self.draw_card_representation(stdscr, y, x, card, is_cursor, is_selected) + + # Draw Status Bar at bottom + stdscr.addstr(h - 2, 2, f"Status: {self.status_msg}"[:w-3], curses.color_pair(4) | curses.A_BOLD) + + # Draw game seed info or undo history count + info_str = f"Undos available: {len(self.game.history)}" + if w > len(info_str) + 10: + stdscr.addstr(h - 2, w - len(info_str) - 3, info_str, curses.color_pair(3)) + stdscr.refresh() - return - - # Title / Help Banner - stdscr.addstr(0, 2, "KLONDIKE SOLITAIRE", curses.color_pair(4) | curses.A_BOLD) - help_text = "Arrows/Vim:Move | Space/Enter:Select/Draw | A:Auto-Move | U:Undo | R:Restart | Q:Quit" - stdscr.addstr(1, 2, help_text[:w-3], curses.color_pair(3)) - - # Top row elements positioning - # Stock (col 0), Waste (col 1), gap (col 2), Foundations 0-3 (cols 3-6) - x_coords = [2 + i * 8 for i in range(7)] - - # --- Draw STOCK --- - is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 0) - is_selected = False # Stock can never be selected - stdscr.addstr(3, x_coords[0], "STOCK", curses.color_pair(3)) - - if self.game.stock: - # Top card of Stock is face down - self.draw_card_representation(stdscr, 4, x_coords[0], Card('S', 1, False), is_cursor, is_selected) - else: - self.draw_card_representation(stdscr, 4, x_coords[0], None, is_cursor, is_selected) - - # --- Draw WASTE --- - is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 1) - is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == 1) - stdscr.addstr(3, x_coords[1], "WASTE", curses.color_pair(3)) - - if self.game.waste: - self.draw_card_representation(stdscr, 4, x_coords[1], self.game.waste[-1], is_cursor, is_selected) - else: - self.draw_card_representation(stdscr, 4, x_coords[1], None, is_cursor, is_selected) - - # --- Draw FOUNDATIONS --- - for i in range(4): - col_idx = 3 + i - is_cursor = (self.cursor_zone == 'top' and self.cursor_col == col_idx) - is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == col_idx) - stdscr.addstr(3, x_coords[col_idx], f"FOUND {i+1}", curses.color_pair(3)) - - pile = self.game.foundations[i] - if pile: - self.draw_card_representation(stdscr, 4, x_coords[col_idx], pile[-1], is_cursor, is_selected) - else: - self.draw_card_representation(stdscr, 4, x_coords[col_idx], None, is_cursor, is_selected) - - # --- Draw TABLEAU --- - stdscr.addstr(6, 2, "TABLEAU COLUMNS:", curses.color_pair(4)) - for col_idx in range(7): - pile = self.game.tableau[col_idx] - x = x_coords[col_idx] - - # Label - stdscr.addstr(7, x, f"COL {col_idx+1}", curses.color_pair(3)) - - if not pile: - is_cursor = (self.cursor_zone == 'bottom' and self.cursor_col == col_idx) - is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'bottom' and self.selected_pos[1] == col_idx) - self.draw_card_representation(stdscr, 8, x, None, is_cursor, is_selected) - else: - for card_idx, card in enumerate(pile): - y = 8 + card_idx - # Check cursor highlighting - is_cursor = ( - self.cursor_zone == 'bottom' and - self.cursor_col == col_idx and - self.cursor_card_idx == card_idx - ) - # Check selection highlighting - # If this column is selected as source, we highlight the selected card and all cards below it! - is_selected = False - if self.selected_pos is not None: - src_zone, src_col, src_card_idx = self.selected_pos - if src_zone == 'bottom' and src_col == col_idx and card_idx >= src_card_idx: - is_selected = True - - self.draw_card_representation(stdscr, y, x, card, is_cursor, is_selected) - - # Draw Status Bar at bottom - stdscr.addstr(h - 2, 2, f"Status: {self.status_msg}"[:w-3], curses.color_pair(4) | curses.A_BOLD) - - # Draw game seed info or undo history count - info_str = f"Undos available: {len(self.game.history)}" - stdscr.addstr(h - 2, w - len(info_str) - 3, info_str, curses.color_pair(3)) - - stdscr.refresh() + except curses.error: + pass def run(self, stdscr) -> None: # Initialize color scheme @@ -398,6 +409,10 @@ class SolitaireTUI: self.selected_pos = None self.status_msg = "New game started!" + # Handle Help + elif ch in (ord('?'), ord('H')): + self.status_msg = "Help: Arrows/Vim to move, Space/Enter to select/draw, A to auto-move, U to undo, R to restart, Q to quit." + # Handle Resize elif ch == curses.KEY_RESIZE: stdscr.clear() diff --git a/solitaire-app/tests/test_game.py b/solitaire-app/tests/test_game.py index 56def882a..d7e4fcf47 100644 --- a/solitaire-app/tests/test_game.py +++ b/solitaire-app/tests/test_game.py @@ -328,5 +328,53 @@ class TestSolitaireGame(unittest.TestCase): self.game.tableau[3] = [Card(suit='C', rank=6, is_face_up=True)] self.assertFalse(self.game.can_move_tableau_to_tableau(src_col=2, dest_col=3, card_idx=0)) + +class TestSolitaireUI(unittest.TestCase): + def setUp(self): + from solitaire_tui.game import GameState + from solitaire_tui.ui import SolitaireTUI + self.game = GameState(seed=42) + self.tui = SolitaireTUI(self.game) + + def test_cursor_navigation(self): + # Starts at 'top', col 0 + self.assertEqual(self.tui.cursor_zone, 'top') + self.assertEqual(self.tui.cursor_col, 0) + + # Move right + self.tui.move_cursor('right') + self.assertEqual(self.tui.cursor_col, 1) + + # Move right again (should skip col 2 and land on col 3) + self.tui.move_cursor('right') + self.assertEqual(self.tui.cursor_col, 3) + + # Move down to tableau + self.tui.move_cursor('down') + self.assertEqual(self.tui.cursor_zone, 'bottom') + self.assertEqual(self.tui.cursor_col, 3) + # Tableau 3 has 4 cards, so card index should be 3 (0-based) + self.assertEqual(self.tui.cursor_card_idx, 3) + + # Move up from bottom of col 3 + # First face-up card of col 3 is at index 3 + self.tui.move_cursor('up') + # Should transition back to top zone because card_idx cannot go above first face-up card + self.assertEqual(self.tui.cursor_zone, 'top') + + def test_ui_action_draw(self): + # Cursor is at top, col 0 (Stock) + self.tui.cursor_zone = 'top' + self.tui.cursor_col = 0 + + self.assertEqual(len(self.game.stock), 24) + self.assertEqual(len(self.game.waste), 0) + + self.tui.handle_action() + self.assertEqual(len(self.game.stock), 23) + self.assertEqual(len(self.game.waste), 1) + self.assertEqual(self.tui.status_msg, "Drawn card.") + + if __name__ == '__main__': unittest.main() diff --git a/status.json b/status.json index 905fb370f..ee6907d7f 100644 --- a/status.json +++ b/status.json @@ -1,3 +1,3 @@ { "outcome": "succeeded" -} +} \ No newline at end of file