mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
fabro(01KT9YT14FHDYA4VTV9DSFG0FY): impl_ui (succeeded)
Fabro-Run: 01KT9YT14FHDYA4VTV9DSFG0FY
Fabro-Completed: 12
Fabro-Checkpoint: 09dcef26f0
⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
parent
c7abba8719
commit
c60c9ef013
5 changed files with 571 additions and 7 deletions
|
|
@ -45,10 +45,6 @@ def main():
|
|||
if "--smoke" in sys.argv:
|
||||
sys.exit(run_smoke_test())
|
||||
else:
|
||||
print("FreeCell Solitaire TUI")
|
||||
print("To run the smoke test, execute with --smoke")
|
||||
print("Starting interactive UI (Placeholder)...")
|
||||
# In future milestones, this will invoke the curses UI
|
||||
from card_game_tui.tui import TUIApp
|
||||
app = TUIApp()
|
||||
app.run()
|
||||
|
|
|
|||
47
card-game-app/src/card_game_tui/render.py
Normal file
47
card-game-app/src/card_game_tui/render.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from typing import Union
|
||||
from card_game_tui.domain import Card, Suit
|
||||
|
||||
def get_card_inner(card: Card) -> str:
|
||||
"""
|
||||
Returns the exact 3-character representation of a card's rank and suit symbol.
|
||||
"""
|
||||
label = card.rank.label
|
||||
symbol = card.suit.symbol
|
||||
return f"{label:>2}{symbol}"
|
||||
|
||||
def get_empty_foundation_inner(suit_idx: int) -> str:
|
||||
"""
|
||||
Returns the suit symbol with padding for an empty foundation slot.
|
||||
"""
|
||||
suits = list(Suit)
|
||||
if 0 <= suit_idx < len(suits):
|
||||
return f" {suits[suit_idx].symbol} "
|
||||
return " "
|
||||
|
||||
def get_card_lines(card: Union[Card, None], is_empty: bool = False, empty_suit_idx: int = -1) -> list[str]:
|
||||
"""
|
||||
Returns 3 lines representing a card or empty cell.
|
||||
"""
|
||||
if is_empty:
|
||||
if empty_suit_idx >= 0:
|
||||
inner = get_empty_foundation_inner(empty_suit_idx)
|
||||
else:
|
||||
inner = " "
|
||||
return [
|
||||
"+---+",
|
||||
f"|{inner}|",
|
||||
"+---+"
|
||||
]
|
||||
else:
|
||||
if card is None:
|
||||
return [
|
||||
"+---+",
|
||||
"| |",
|
||||
"+---+"
|
||||
]
|
||||
inner = get_card_inner(card)
|
||||
return [
|
||||
"+---+",
|
||||
f"|{inner}|",
|
||||
"+---+"
|
||||
]
|
||||
|
|
@ -1,8 +1,427 @@
|
|||
# TUI module placeholder for FreeCell Solitaire TUI
|
||||
import curses
|
||||
import random
|
||||
import sys
|
||||
from typing import Union, List, Optional
|
||||
from card_game_tui.domain import GameState, Position, LocationType, Suit, Card
|
||||
from card_game_tui.render import get_card_inner, get_empty_foundation_inner
|
||||
|
||||
class TUIApp:
|
||||
def __init__(self):
|
||||
pass
|
||||
# We generate a random seed for the game to start with, e.g. 1 to 99999
|
||||
self.current_seed = random.randint(1, 99999)
|
||||
self.state = GameState(seed=self.current_seed)
|
||||
|
||||
# Cursor and Selection State
|
||||
self.cursor_row = 1 # 0: Top row (Freecells/Foundations), 1: Tableau Columns
|
||||
self.cursor_col = 0 # 0 to 7
|
||||
|
||||
self.selected_pos: Optional[Position] = None
|
||||
self.selected_count = 1
|
||||
|
||||
self.message = "Welcome to FreeCell! Select a card to begin."
|
||||
self.message_attr = 0 # Will be set to color pair
|
||||
|
||||
self.quit_confirm = False
|
||||
|
||||
def reset_game(self, seed: int):
|
||||
self.current_seed = seed
|
||||
self.state = GameState(seed=seed)
|
||||
self.selected_pos = None
|
||||
self.selected_count = 1
|
||||
self.cursor_row = 1
|
||||
self.cursor_col = 0
|
||||
self.message = f"Game started with seed: {self.current_seed}"
|
||||
self.message_attr = 0
|
||||
self.quit_confirm = False
|
||||
|
||||
def get_current_pos(self) -> Position:
|
||||
if self.cursor_row == 0:
|
||||
if self.cursor_col < 4:
|
||||
return Position(LocationType.FREECELL, self.cursor_col)
|
||||
else:
|
||||
return Position(LocationType.FOUNDATION, self.cursor_col - 4)
|
||||
else:
|
||||
return Position(LocationType.TABLEAU, self.cursor_col)
|
||||
|
||||
def find_max_valid_count(self, from_pos: Position, to_pos: Position) -> int:
|
||||
if from_pos.type != LocationType.TABLEAU:
|
||||
return 1
|
||||
col = self.state.tableaus[from_pos.index]
|
||||
if not col:
|
||||
return 0
|
||||
|
||||
# Find maximum length of alternating, descending sequence at bottom
|
||||
max_seq = 1
|
||||
for i in range(len(col) - 2, -1, -1):
|
||||
c1 = col[i]
|
||||
c2 = col[i+1]
|
||||
if c2.rank.value == c1.rank.value - 1 and c2.suit.color != c1.suit.color:
|
||||
max_seq += 1
|
||||
else:
|
||||
break
|
||||
|
||||
# Find the maximum count that is legally movable to destination
|
||||
for count in range(max_seq, 0, -1):
|
||||
if self.state.validate_move(from_pos, to_pos, count):
|
||||
return count
|
||||
return 0
|
||||
|
||||
def find_longest_sequence(self, col_idx: int) -> int:
|
||||
col = self.state.tableaus[col_idx]
|
||||
if not col:
|
||||
return 0
|
||||
max_seq = 1
|
||||
for i in range(len(col) - 2, -1, -1):
|
||||
c1 = col[i]
|
||||
c2 = col[i+1]
|
||||
if c2.rank.value == c1.rank.value - 1 and c2.suit.color != c1.suit.color:
|
||||
max_seq += 1
|
||||
else:
|
||||
break
|
||||
return max_seq
|
||||
|
||||
def safe_addstr(self, stdscr, y: int, x: int, text: str, attr: int = 0):
|
||||
try:
|
||||
if y < self.max_y and x < self.max_x:
|
||||
available = self.max_x - x
|
||||
if len(text) > available:
|
||||
text = text[:available]
|
||||
stdscr.addstr(y, x, text, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
def draw_board(self, stdscr):
|
||||
# Clear screen
|
||||
stdscr.erase()
|
||||
|
||||
# Draw Title
|
||||
title_attr = curses.color_pair(3) | curses.A_BOLD
|
||||
self.safe_addstr(stdscr, 1, 2, "FREECELL SOLITAIRE", title_attr)
|
||||
self.safe_addstr(stdscr, 1, 55, f"[SEED: {self.current_seed}]", curses.color_pair(4))
|
||||
|
||||
# Labels
|
||||
label_attr = curses.color_pair(4) | curses.A_BOLD
|
||||
self.safe_addstr(stdscr, 3, 2, "Free Cells", label_attr)
|
||||
self.safe_addstr(stdscr, 3, 34, "Foundations", label_attr)
|
||||
|
||||
# Free Cells (Cols 0-3 on row 0)
|
||||
for i in range(4):
|
||||
x = 2 + i * 8
|
||||
y = 4
|
||||
is_cursor = (self.cursor_row == 0 and self.cursor_col == i)
|
||||
|
||||
# Draw header label [A], [B], [C], [D]
|
||||
header_attr = (curses.color_pair(3) | curses.A_BOLD) if is_cursor else curses.A_NORMAL
|
||||
self.safe_addstr(stdscr, y - 1, x, f" [{chr(65 + i)}] ", header_attr)
|
||||
|
||||
card = self.state.freecells[i]
|
||||
border_attr = curses.color_pair(3) if is_cursor else curses.A_NORMAL
|
||||
is_selected = (self.selected_pos is not None and self.selected_pos.type == LocationType.FREECELL and self.selected_pos.index == i)
|
||||
|
||||
if card is None:
|
||||
self.safe_addstr(stdscr, y, x, "+---+", border_attr)
|
||||
self.safe_addstr(stdscr, y + 1, x, "| |", border_attr)
|
||||
self.safe_addstr(stdscr, y + 2, x, "+---+", border_attr)
|
||||
else:
|
||||
card_attr = curses.color_pair(1) if card.suit.color == "RED" else curses.color_pair(2)
|
||||
if is_selected:
|
||||
card_attr |= curses.A_REVERSE
|
||||
self.safe_addstr(stdscr, y, x, "+---+", border_attr)
|
||||
self.safe_addstr(stdscr, y + 1, x, f"|{get_card_inner(card)}|", card_attr)
|
||||
self.safe_addstr(stdscr, y + 2, x, "+---+", border_attr)
|
||||
|
||||
# Foundations (Cols 4-7 on row 0)
|
||||
for i in range(4):
|
||||
x = 34 + i * 8
|
||||
y = 4
|
||||
is_cursor = (self.cursor_row == 0 and self.cursor_col == i + 4)
|
||||
|
||||
suit = list(Suit)[i]
|
||||
header_attr = (curses.color_pair(3) | curses.A_BOLD) if is_cursor else curses.A_NORMAL
|
||||
self.safe_addstr(stdscr, y - 1, x, f" [{suit.symbol}] ", header_attr)
|
||||
|
||||
pile = self.state.foundations[suit]
|
||||
border_attr = curses.color_pair(3) if is_cursor else curses.A_NORMAL
|
||||
|
||||
if not pile:
|
||||
inner = get_empty_foundation_inner(i)
|
||||
inner_attr = curses.color_pair(1) if suit.color == "RED" else curses.color_pair(2)
|
||||
inner_attr |= curses.A_DIM
|
||||
self.safe_addstr(stdscr, y, x, "+---+", border_attr)
|
||||
self.safe_addstr(stdscr, y + 1, x, f"|{inner}|", inner_attr)
|
||||
self.safe_addstr(stdscr, y + 2, x, "+---+", border_attr)
|
||||
else:
|
||||
card = pile[-1]
|
||||
card_attr = curses.color_pair(1) if card.suit.color == "RED" else curses.color_pair(2)
|
||||
self.safe_addstr(stdscr, y, x, "+---+", border_attr)
|
||||
self.safe_addstr(stdscr, y + 1, x, f"|{get_card_inner(card)}|", card_attr)
|
||||
self.safe_addstr(stdscr, y + 2, x, "+---+", border_attr)
|
||||
|
||||
# Tableau columns
|
||||
self.safe_addstr(stdscr, 8, 2, "Tableau Columns", label_attr)
|
||||
|
||||
for col_idx in range(8):
|
||||
x = 2 + col_idx * 8
|
||||
y = 10
|
||||
is_cursor_col = (self.cursor_row == 1 and self.cursor_col == col_idx)
|
||||
|
||||
# Header label [1] to [8]
|
||||
header_attr = (curses.color_pair(3) | curses.A_BOLD) if is_cursor_col else curses.A_NORMAL
|
||||
self.safe_addstr(stdscr, y - 1, x, f" [{col_idx + 1}] ", header_attr)
|
||||
|
||||
col = self.state.tableaus[col_idx]
|
||||
|
||||
if not col:
|
||||
empty_attr = curses.color_pair(3) if is_cursor_col else curses.A_DIM
|
||||
self.safe_addstr(stdscr, y, x, "+---+", empty_attr)
|
||||
self.safe_addstr(stdscr, y + 1, x, "| |", empty_attr)
|
||||
self.safe_addstr(stdscr, y + 2, x, "+---+", empty_attr)
|
||||
else:
|
||||
# Draw stacked cards
|
||||
top_border_attr = curses.color_pair(3) if is_cursor_col else curses.A_NORMAL
|
||||
self.safe_addstr(stdscr, y, x, "+---+", top_border_attr)
|
||||
|
||||
for card_idx, card in enumerate(col):
|
||||
is_selected_card = False
|
||||
if (self.selected_pos is not None and
|
||||
self.selected_pos.type == LocationType.TABLEAU and
|
||||
self.selected_pos.index == col_idx):
|
||||
if card_idx >= len(col) - self.selected_count:
|
||||
is_selected_card = True
|
||||
|
||||
card_attr = curses.color_pair(1) if card.suit.color == "RED" else curses.color_pair(2)
|
||||
if is_selected_card:
|
||||
card_attr |= curses.A_REVERSE
|
||||
|
||||
card_y = y + 1 + card_idx
|
||||
self.safe_addstr(stdscr, card_y, x, f"|{get_card_inner(card)}|", card_attr)
|
||||
|
||||
bottom_y = y + 1 + len(col)
|
||||
bottom_border_attr = curses.color_pair(3) if is_cursor_col else curses.A_NORMAL
|
||||
self.safe_addstr(stdscr, bottom_y, x, "+---+", bottom_border_attr)
|
||||
|
||||
# Status & Message bar (fixed near bottom of standard screen size or max_y)
|
||||
msg_y = max(20, self.max_y - 4)
|
||||
sep_y = msg_y - 1
|
||||
keys_y = msg_y + 1
|
||||
|
||||
# Horizontal separator
|
||||
self.safe_addstr(stdscr, sep_y, 0, "-" * self.max_x, curses.A_DIM)
|
||||
|
||||
# Message string
|
||||
self.safe_addstr(stdscr, msg_y, 2, f"MSG: {self.message}", self.message_attr)
|
||||
|
||||
# Keys bar
|
||||
keys_text = "KEYS: [Arrows/WASD] Navigate [Space/Enter] Select/Drop [U] Undo [Y] Redo [R] Restart [N] New [Q] Quit"
|
||||
if self.selected_pos is not None:
|
||||
longest_seq = self.find_longest_sequence(self.selected_pos.index) if self.selected_pos.type == LocationType.TABLEAU else 1
|
||||
if longest_seq > 1:
|
||||
keys_text = f"KEYS: [-/+] Move Count: {self.selected_count}/{longest_seq} [Arrows/WASD] Navigate [Space] Drop [Esc] Cancel"
|
||||
|
||||
self.safe_addstr(stdscr, keys_y, 2, keys_text, curses.color_pair(4) | curses.A_BOLD)
|
||||
|
||||
def handle_input(self, key: int) -> bool:
|
||||
"""
|
||||
Processes key inputs. Returns True if we should keep running, False if we should exit.
|
||||
"""
|
||||
# If in Quit Confirmation state
|
||||
if self.quit_confirm:
|
||||
if key in (ord('y'), ord('Y')):
|
||||
return False
|
||||
else:
|
||||
self.quit_confirm = False
|
||||
self.message = "Quit canceled. Game resumed."
|
||||
self.message_attr = curses.color_pair(4)
|
||||
return True
|
||||
|
||||
# Standard navigation keys
|
||||
# UP: Arrow UP, w, W, k, K
|
||||
if key in (curses.KEY_UP, ord('w'), ord('W'), ord('k'), ord('K')):
|
||||
self.cursor_row = 0
|
||||
self.message_attr = 0
|
||||
# DOWN: Arrow DOWN, s, S, j, J
|
||||
elif key in (curses.KEY_DOWN, ord('s'), ord('S'), ord('j'), ord('J')):
|
||||
self.cursor_row = 1
|
||||
self.message_attr = 0
|
||||
# LEFT: Arrow LEFT, a, A, h, H
|
||||
elif key in (curses.KEY_LEFT, ord('a'), ord('A'), ord('h'), ord('H')):
|
||||
self.cursor_col = (self.cursor_col - 1) % 8
|
||||
self.message_attr = 0
|
||||
# RIGHT: Arrow RIGHT, d, D, l, L
|
||||
elif key in (curses.KEY_RIGHT, ord('d'), ord('D'), ord('l'), ord('L')):
|
||||
self.cursor_col = (self.cursor_col + 1) % 8
|
||||
self.message_attr = 0
|
||||
|
||||
# Undo key: u, U
|
||||
elif key in (ord('u'), ord('U')):
|
||||
if self.state.undo():
|
||||
self.message = "Undo successful."
|
||||
self.message_attr = curses.color_pair(5)
|
||||
self.selected_pos = None
|
||||
else:
|
||||
self.message = "Nothing to undo!"
|
||||
self.message_attr = curses.color_pair(1)
|
||||
|
||||
# Redo key: y, Y or Ctrl-Y (25) or Ctrl-R (18)
|
||||
elif key in (ord('y'), ord('Y'), 25, 18):
|
||||
if self.state.redo():
|
||||
self.message = "Redo successful."
|
||||
self.message_attr = curses.color_pair(5)
|
||||
self.selected_pos = None
|
||||
else:
|
||||
self.message = "Nothing to redo!"
|
||||
self.message_attr = curses.color_pair(1)
|
||||
|
||||
# Restart key: r, R
|
||||
elif key in (ord('r'), ord('R')):
|
||||
self.reset_game(self.current_seed)
|
||||
self.message = f"Game restarted. Seed: {self.current_seed}"
|
||||
self.message_attr = curses.color_pair(4)
|
||||
|
||||
# New Game key: n, N
|
||||
elif key in (ord('n'), ord('N')):
|
||||
new_seed = random.randint(1, 99999)
|
||||
self.reset_game(new_seed)
|
||||
|
||||
# Quit key: q, Q
|
||||
elif key in (ord('q'), ord('Q')):
|
||||
self.quit_confirm = True
|
||||
self.message = "Are you sure you want to quit? (y/n)"
|
||||
self.message_attr = curses.color_pair(1) | curses.A_BOLD
|
||||
|
||||
# Cancel Selection: Escape key (27)
|
||||
elif key == 27:
|
||||
if self.selected_pos is not None:
|
||||
self.selected_pos = None
|
||||
self.selected_count = 1
|
||||
self.message = "Selection canceled."
|
||||
self.message_attr = curses.color_pair(4)
|
||||
|
||||
# Selection Count adjustments (only valid when a Tableau selection is active)
|
||||
elif key in (ord('-'), ord('_'), ord('[')) and self.selected_pos is not None and self.selected_pos.type == LocationType.TABLEAU:
|
||||
if self.selected_count > 1:
|
||||
self.selected_count -= 1
|
||||
self.message = f"Adjusted selection count to {self.selected_count}."
|
||||
self.message_attr = curses.color_pair(4)
|
||||
|
||||
elif key in (ord('+'), ord('='), ord(']')) and self.selected_pos is not None and self.selected_pos.type == LocationType.TABLEAU:
|
||||
longest_seq = self.find_longest_sequence(self.selected_pos.index)
|
||||
if self.selected_count < longest_seq:
|
||||
self.selected_count += 1
|
||||
self.message = f"Adjusted selection count to {self.selected_count}."
|
||||
self.message_attr = curses.color_pair(4)
|
||||
|
||||
# Space (32) or Enter (10, 13, curses.KEY_ENTER)
|
||||
elif key in (32, 10, 13, curses.KEY_ENTER):
|
||||
curr_pos = self.get_current_pos()
|
||||
|
||||
if self.selected_pos is None:
|
||||
# Attempt to select
|
||||
if curr_pos.type == LocationType.FOUNDATION:
|
||||
self.message = "Cannot pick up cards from the foundation piles."
|
||||
self.message_attr = curses.color_pair(1)
|
||||
else:
|
||||
card = self.state.get_card_at(curr_pos)
|
||||
if card is None:
|
||||
self.message = "That slot is empty!"
|
||||
self.message_attr = curses.color_pair(1)
|
||||
else:
|
||||
self.selected_pos = curr_pos
|
||||
# Default count to 1
|
||||
self.selected_count = 1
|
||||
|
||||
# Automatically compute max valid count if destination is a Tableau
|
||||
# We will keep count as 1, but user can adjust or we automatically resolve on execute.
|
||||
self.message = f"Selected {card}. Use [-/+] to change count. Select destination and press Space."
|
||||
self.message_attr = curses.color_pair(4)
|
||||
else:
|
||||
# Destination select / execute move
|
||||
if curr_pos == self.selected_pos:
|
||||
# Clicking source again deselects
|
||||
self.selected_pos = None
|
||||
self.selected_count = 1
|
||||
self.message = "Selection cleared."
|
||||
self.message_attr = curses.color_pair(4)
|
||||
else:
|
||||
# Let's perform move execution
|
||||
# First, if moving Tableau-to-Tableau, let's automatically check if a sequence move is valid
|
||||
# and if we should adjust selected_count if user didn't specify one larger than 1.
|
||||
count_to_use = self.selected_count
|
||||
if self.selected_pos.type == LocationType.TABLEAU and curr_pos.type == LocationType.TABLEAU:
|
||||
# If user is moving a sequence and selected_count is 1, let's find if a larger sequence is valid and use it.
|
||||
# This auto-moves the sequence by default if they didn't manually change count, matching standard games.
|
||||
max_valid = self.find_max_valid_count(self.selected_pos, curr_pos)
|
||||
if max_valid > count_to_use:
|
||||
count_to_use = max_valid
|
||||
|
||||
if self.state.execute_move(self.selected_pos, curr_pos, count=count_to_use):
|
||||
self.message = "Move successful!"
|
||||
self.message_attr = curses.color_pair(5)
|
||||
|
||||
# Check win condition
|
||||
if self.state.check_win():
|
||||
self.message = "CONGRATULATIONS! YOU HAVE WON THE GAME! Press N for new game, Q to quit."
|
||||
self.message_attr = curses.color_pair(5) | curses.A_BLINK | curses.A_BOLD
|
||||
# Check loss condition
|
||||
elif self.state.check_loss():
|
||||
self.message = "GAME OVER! No legal moves remaining. Press R to restart, N for new game, Q to quit."
|
||||
self.message_attr = curses.color_pair(1) | curses.A_BOLD
|
||||
|
||||
self.selected_pos = None
|
||||
self.selected_count = 1
|
||||
else:
|
||||
# Show precise validation error
|
||||
self.message = "Invalid move under FreeCell rules!"
|
||||
self.message_attr = curses.color_pair(1)
|
||||
# We do NOT clear selection on invalid move, so user can try another target!
|
||||
|
||||
return True
|
||||
|
||||
def _main_loop(self, stdscr) -> None:
|
||||
# Curses configurations
|
||||
curses.curs_set(0) # Hide hardware cursor
|
||||
stdscr.keypad(True) # Enable arrow keys
|
||||
stdscr.nodelay(False) # Wait for keys
|
||||
|
||||
# Set up color pairs
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
# Pair 1: Red cards
|
||||
curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK)
|
||||
# Pair 2: Black cards
|
||||
curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLACK)
|
||||
# Pair 3: Headers / Active Selection
|
||||
curses.init_pair(3, curses.COLOR_CYAN, curses.COLOR_BLACK)
|
||||
# Pair 4: Keys / Info
|
||||
curses.init_pair(4, curses.COLOR_YELLOW, curses.COLOR_BLACK)
|
||||
# Pair 5: Success / Win
|
||||
curses.init_pair(5, curses.COLOR_GREEN, curses.COLOR_BLACK)
|
||||
|
||||
while True:
|
||||
# Get current window boundaries
|
||||
self.max_y, self.max_x = stdscr.getmaxyx()
|
||||
|
||||
# Draw the game board
|
||||
self.draw_board(stdscr)
|
||||
|
||||
# Perform screen updates
|
||||
stdscr.refresh()
|
||||
|
||||
# Read next keyboard input
|
||||
try:
|
||||
key = stdscr.getch()
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
# Handle the keyboard input
|
||||
if not self.handle_input(key):
|
||||
break
|
||||
|
||||
def run(self) -> None:
|
||||
print("TUI App placeholder running.")
|
||||
try:
|
||||
curses.wrapper(self._main_loop)
|
||||
except Exception as e:
|
||||
# Restore terminal cleanly on error
|
||||
print(f"Error in curses TUI: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
|
|
|||
29
card-game-app/tests/test_render.py
Normal file
29
card-game-app/tests/test_render.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from card_game_tui.domain import Card, Suit, Rank
|
||||
from card_game_tui.render import get_card_inner, get_empty_foundation_inner, get_card_lines
|
||||
|
||||
def test_get_card_inner():
|
||||
# Test single-char rank label
|
||||
card_ah = Card(Suit.HEARTS, Rank.ACE)
|
||||
assert get_card_inner(card_ah) == " A♥"
|
||||
|
||||
# Test double-char rank label
|
||||
card_10s = Card(Suit.SPADES, Rank.TEN)
|
||||
assert get_card_inner(card_10s) == "10♠"
|
||||
|
||||
# Test face card
|
||||
card_kd = Card(Suit.DIAMONDS, Rank.KING)
|
||||
assert get_card_inner(card_kd) == " K♦"
|
||||
|
||||
def test_get_empty_foundation_inner():
|
||||
assert get_empty_foundation_inner(0) == " ♥ "
|
||||
assert get_empty_foundation_inner(1) == " ♦ "
|
||||
assert get_empty_foundation_inner(2) == " ♣ "
|
||||
assert get_empty_foundation_inner(3) == " ♠ "
|
||||
assert get_empty_foundation_inner(4) == " "
|
||||
|
||||
def test_get_card_lines():
|
||||
card_ah = Card(Suit.HEARTS, Rank.ACE)
|
||||
assert get_card_lines(card_ah) == ["+---+", "| A♥|", "+---+"]
|
||||
assert get_card_lines(None) == ["+---+", "| |", "+---+"]
|
||||
assert get_card_lines(None, is_empty=True, empty_suit_idx=0) == ["+---+", "| ♥ |", "+---+"]
|
||||
assert get_card_lines(None, is_empty=True, empty_suit_idx=-1) == ["+---+", "| |", "+---+"]
|
||||
73
card-game-app/tests/test_tui.py
Normal file
73
card-game-app/tests/test_tui.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
from card_game_tui.tui import TUIApp
|
||||
from card_game_tui.domain import Position, LocationType, Card, Suit, Rank
|
||||
|
||||
def test_tui_initialization():
|
||||
app = TUIApp()
|
||||
assert app.cursor_row == 1
|
||||
assert app.cursor_col == 0
|
||||
assert app.selected_pos is None
|
||||
assert app.selected_count == 1
|
||||
assert app.quit_confirm is False
|
||||
|
||||
def test_tui_reset_game():
|
||||
app = TUIApp()
|
||||
app.reset_game(12345)
|
||||
assert app.current_seed == 12345
|
||||
assert app.selected_pos is None
|
||||
assert app.cursor_row == 1
|
||||
assert app.cursor_col == 0
|
||||
|
||||
def test_tui_get_current_pos():
|
||||
app = TUIApp()
|
||||
# Row 1, Col 3 should be Tableau index 3
|
||||
app.cursor_row = 1
|
||||
app.cursor_col = 3
|
||||
pos = app.get_current_pos()
|
||||
assert pos.type == LocationType.TABLEAU
|
||||
assert pos.index == 3
|
||||
|
||||
# Row 0, Col 2 should be FreeCell 2
|
||||
app.cursor_row = 0
|
||||
app.cursor_col = 2
|
||||
pos = app.get_current_pos()
|
||||
assert pos.type == LocationType.FREECELL
|
||||
assert pos.index == 2
|
||||
|
||||
# Row 0, Col 5 should be Foundation 1 (5 - 4)
|
||||
app.cursor_row = 0
|
||||
app.cursor_col = 5
|
||||
pos = app.get_current_pos()
|
||||
assert pos.type == LocationType.FOUNDATION
|
||||
assert pos.index == 1
|
||||
|
||||
def test_tui_find_longest_sequence():
|
||||
app = TUIApp()
|
||||
app.state.tableaus = [[] for _ in range(8)]
|
||||
|
||||
# Empty column
|
||||
assert app.find_longest_sequence(0) == 0
|
||||
|
||||
# Non-empty column
|
||||
# Red 10, Black 9, Red 8 (descending and alternating)
|
||||
c10 = Card(Suit.HEARTS, Rank.TEN)
|
||||
c9 = Card(Suit.SPADES, Rank.NINE)
|
||||
c8 = Card(Suit.DIAMONDS, Rank.EIGHT)
|
||||
app.state.tableaus[0] = [Card(Suit.CLUBS, Rank.KING), c10, c9, c8]
|
||||
assert app.find_longest_sequence(0) == 3
|
||||
|
||||
def test_tui_find_max_valid_count():
|
||||
app = TUIApp()
|
||||
app.state.tableaus = [[] for _ in range(8)]
|
||||
app.state.freecells = [None] * 4
|
||||
|
||||
# From Empty Tableau
|
||||
from_pos = Position(LocationType.TABLEAU, 0)
|
||||
to_pos = Position(LocationType.TABLEAU, 1)
|
||||
assert app.find_max_valid_count(from_pos, to_pos) == 0
|
||||
|
||||
# With some sequence
|
||||
c10 = Card(Suit.HEARTS, Rank.TEN)
|
||||
c9 = Card(Suit.SPADES, Rank.NINE)
|
||||
app.state.tableaus[0] = [c10, c9]
|
||||
# Destination is empty column, max possible sequence to move is 2
|
||||
assert app.find_max_valid_count(from_pos, to_pos) == 2
|
||||
Loading…
Add table
Reference in a new issue