Complete native rewrite of the web-based SoliCards game as a SwiftUI multiplatform app targeting iOS 17+, iPadOS 17+, and macOS 14+. Three solitaire variants (Klondike, Spider, FreeCell) with full game rules, drag & drop, smart zoom layout, 6 themes, 4 difficulty levels, SwiftData persistence, VoiceOver accessibility, and 57 unit tests. Key features: - MVVM + Protocol-Oriented Strategy architecture - DragGesture with coordinate-space hit-testing (long press + drag) - Smart zoom: cards auto-size to fit screen based on deepest column - Landscape: 30% bigger cards with scrollable overflow (iOS) - macOS: 120pt card cap, 92% height buffer for window resizing - Auto-save, game resume, statistics tracking via SwiftData - Privacy manifest, app icon, String Catalog, zero dependencies Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
88 lines
2.9 KiB
Swift
88 lines
2.9 KiB
Swift
import Testing
|
||
@testable import SoliCards
|
||
|
||
@Suite("FreeCell Rules Tests")
|
||
struct FreeCellRulesTests {
|
||
let rules = FreeCellRules()
|
||
|
||
@Test("Deal distributes all 52 cards face-up")
|
||
func dealLayout() {
|
||
let deck = Deck.standard()
|
||
let snapshot = rules.deal(deck: deck)
|
||
|
||
#expect(snapshot.tableaus.count == 8)
|
||
// First 4 tableaus: 7 cards, last 4: 6 cards
|
||
for i in 0..<4 {
|
||
#expect(snapshot.tableaus[i].count == 7)
|
||
}
|
||
for i in 4..<8 {
|
||
#expect(snapshot.tableaus[i].count == 6)
|
||
}
|
||
// All face-up
|
||
let allCards = snapshot.tableaus.flatMap { $0 }
|
||
#expect(allCards.count == 52)
|
||
#expect(allCards.allSatisfy { $0.isFaceUp })
|
||
|
||
// 4 empty free cells
|
||
#expect(snapshot.freeCells.count == 4)
|
||
#expect(snapshot.freeCells.allSatisfy { $0 == nil })
|
||
|
||
// No stock
|
||
#expect(snapshot.stock.isEmpty)
|
||
}
|
||
|
||
@Test("Power move calculation")
|
||
func powerMoves() {
|
||
// (1 + empty_freecells) × 2^empty_tableaus
|
||
var snapshot = emptySnapshot()
|
||
// All 4 free cells empty, target is empty (so 7 empty tableaus count)
|
||
let max1 = rules.calculateMaxMovableCards(state: snapshot, targetEmpty: true)
|
||
// (1 + 4) × 2^7 = 5 × 128 = 640
|
||
#expect(max1 == 640)
|
||
|
||
// 2 free cells occupied, no empty tableaus
|
||
snapshot.freeCells = [Card(suit: .hearts, rank: .ace, isFaceUp: true),
|
||
Card(suit: .spades, rank: .two, isFaceUp: true),
|
||
nil, nil]
|
||
for i in 0..<8 {
|
||
snapshot.tableaus[i] = [Card(suit: .clubs, rank: .king, isFaceUp: true)]
|
||
}
|
||
let max2 = rules.calculateMaxMovableCards(state: snapshot, targetEmpty: false)
|
||
// (1 + 2) × 2^0 = 3
|
||
#expect(max2 == 3)
|
||
}
|
||
|
||
@Test("Can move single card to empty free cell")
|
||
func moveToFreeCell() {
|
||
let card = Card(suit: .hearts, rank: .five, isFaceUp: true)
|
||
var snapshot = emptySnapshot()
|
||
snapshot.tableaus[0] = [card]
|
||
|
||
#expect(rules.canMove(cards: [card], from: .tableau(0), to: .freeCell(0), state: snapshot))
|
||
}
|
||
|
||
@Test("Cannot move multiple cards to free cell")
|
||
func multipleToFreeCell() {
|
||
let card1 = Card(suit: .spades, rank: .six, isFaceUp: true)
|
||
let card2 = Card(suit: .hearts, rank: .five, isFaceUp: true)
|
||
var snapshot = emptySnapshot()
|
||
snapshot.tableaus[0] = [card1, card2]
|
||
|
||
#expect(!rules.canMove(cards: [card1, card2], from: .tableau(0), to: .freeCell(0), state: snapshot))
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
private func emptySnapshot() -> GameSnapshot {
|
||
GameSnapshot(
|
||
tableaus: Array(repeating: [], count: 8),
|
||
foundations: [[], [], [], []],
|
||
stock: [],
|
||
waste: [],
|
||
freeCells: [nil, nil, nil, nil],
|
||
moves: 0,
|
||
score: 0
|
||
)
|
||
}
|
||
}
|