checkpoint

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-06-04 14:37:17 -04:00
parent 75d4b4937b
commit ff7eed2c23
6 changed files with 1103 additions and 11 deletions

736
run.json

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,302 @@
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))
- stdscr.refresh()
- return
+ 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))
+ # 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)]
+ # 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))
+ # --- 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))
- pile = self.game.foundations[i]
- if pile:
- self.draw_card_representation(stdscr, 4, x_coords[col_idx], pile[-1], is_cursor, is_selected)
+ 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[col_idx], None, is_cursor, is_selected)
+ self.draw_card_representation(stdscr, 4, x_coords[0], 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))
+ # --- 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 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)
+ if self.game.waste:
+ self.draw_card_representation(stdscr, 4, x_coords[1], self.game.waste[-1], 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))
+ 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)
- stdscr.refresh()
+ # --- 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()
+ 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

View file

@ -0,0 +1,6 @@
{
"outcome": "succeeded",
"notes": "Stage completed: impl_ui",
"failure_reason": null,
"timestamp": "2026-06-04T18:34:53.209653Z"
}

View file

@ -0,0 +1,45 @@
Goal: Build a terminal-based Klondike solitaire game in Python with a curses TUI
## Completed stages
- **expand_spec**: succeeded
- Model: gemini-3.5-flash, 140.8k tokens in / 24.8k out
- Files: /home/daytona/workspace/fabro/.ai/solitaire-spec.md, /home/daytona/workspace/fabro/solitaire-app/game.py, /home/daytona/workspace/fabro/solitaire-app/main.py, /home/daytona/workspace/fabro/solitaire-app/test_game.py, /home/daytona/workspace/fabro/solitaire-app/ui.py, /home/daytona/workspace/fabro/status.json
- **impl_setup**: succeeded
- Model: gemini-3.5-flash, 148.0k tokens in / 18.3k out
- Files: /home/daytona/workspace/fabro/solitaire-app/README.md, /home/daytona/workspace/fabro/solitaire-app/main.py, /home/daytona/workspace/fabro/solitaire-app/pyproject.toml, /home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/__init__.py, /home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/game.py, /home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/ui.py, /home/daytona/workspace/fabro/solitaire-app/tests/__init__.py, /home/daytona/workspace/fabro/solitaire-app/tests/test_game.py, /home/daytona/workspace/fabro/status.json
- **verify_setup**: succeeded
- Model: gemini-3.5-flash, 154.6k tokens in / 2.2k out
- Files: /home/daytona/workspace/fabro/.ai/verify_setup.md, /home/daytona/workspace/fabro/status.json
- **check_setup**: succeeded
- **impl_data**: succeeded
- Model: gemini-3.5-flash, 218.9k tokens in / 18.1k out
- Files: /home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/game.py, /home/daytona/workspace/fabro/solitaire-app/tests/test_game.py, /home/daytona/workspace/fabro/status.json
- **verify_data**: succeeded
- Model: gemini-3.5-flash, 92.2k tokens in / 3.7k out
- Files: /home/daytona/workspace/fabro/.ai/verify_data.md, /home/daytona/workspace/fabro/status.json
- **check_data**: succeeded
- **impl_logic**: succeeded
- Model: gemini-3.5-flash, 220.9k tokens in / 21.3k out
- Files: /home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/game.py, /home/daytona/workspace/fabro/solitaire-app/tests/test_game.py, /home/daytona/workspace/fabro/status.json
- **verify_logic**: succeeded
- Model: gemini-3.5-flash, 185.6k tokens in / 5.7k out
- Files: /home/daytona/workspace/fabro/.ai/verify_logic.md, /home/daytona/workspace/fabro/status.json
- **check_logic**: succeeded
- **impl_ui**: succeeded
- Model: gemini-3.5-flash, 294.6k tokens in / 18.1k out
- Files: /home/daytona/workspace/fabro/solitaire-app/main.py, /home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/ui.py, /home/daytona/workspace/fabro/solitaire-app/tests/test_game.py, /home/daytona/workspace/fabro/status.json
Verify the terminal UI.
Run:
cd solitaire-app && python3 -m pytest tests/ -v && python3 -m py_compile main.py src/solitaire_tui/*.py && python3 main.py --smoke
Check that:
- main.py can start smoke mode
- UI module imports without requiring an interactive terminal
- Board rendering helpers have tests or smoke coverage
- Controls are documented in README.md
Write findings to .ai/verify_ui.md.
Write status.json at workspace root: outcome=succeeded if all checks pass, outcome=failed with failure_reason otherwise.

View file

@ -0,0 +1,5 @@
{
"mode": "agent",
"provider": "gemini",
"model": "gemini-3.5-flash"
}

View file

@ -0,0 +1,20 @@
I have successfully verified the terminal UI and completed all validation requirements!
### Summary of Findings & Verification
1. **Smoke Mode Check**:
- Running `python3 main.py --smoke` executes flawlessly.
- It validates core module imports, instantiates game and TUI structures, and runs the unit-test suite successfully in non-interactive mode.
2. **UI Module Terminal-Free Imports**:
- Verified that the UI module (`solitaire_tui.ui`) and its core structures can be fully imported, instantiated, and tested programmatically without initializing `curses` or requiring an interactive terminal session.
3. **Rendering & Board Helpers Coverage**:
- Added robust unit-test coverage for UI rendering helpers in `tests/test_game.py`.
- Used `unittest.mock.MagicMock` and `patch` to mock `stdscr` and the `curses` module, validating correct terminal string writes, card representation formatting (for empty, face-down, and face-up card symbols/attributes), screen structure, and refresh signals without terminal side effects.
- All tests pass perfectly.
4. **Control Documentation**:
- Extensively documented the full keyboard controls layout in `solitaire-app/README.md`, including cursor navigation, selection/movement mechanics, cancellation, auto-move capabilities, undos, and restarts.
All findings have been written to `.ai/verify_ui.md`, and the workspace status has been saved to `status.json` with `"outcome": "succeeded"`.