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>
117 lines
3.9 KiB
Swift
117 lines
3.9 KiB
Swift
import Testing
|
|
@testable import SoliCards
|
|
|
|
@Suite("Klondike Rules Tests")
|
|
struct KlondikeRulesTests {
|
|
let rules = KlondikeRules()
|
|
|
|
@Test("Deal creates correct tableau layout")
|
|
func dealLayout() {
|
|
let deck = Deck.standard()
|
|
let snapshot = rules.deal(deck: deck)
|
|
|
|
// 7 tableaus with 1,2,3,4,5,6,7 cards
|
|
#expect(snapshot.tableaus.count == 7)
|
|
for i in 0..<7 {
|
|
#expect(snapshot.tableaus[i].count == i + 1)
|
|
// Top card face-up
|
|
#expect(snapshot.tableaus[i].last!.isFaceUp)
|
|
// All others face-down
|
|
for j in 0..<i {
|
|
#expect(!snapshot.tableaus[i][j].isFaceUp)
|
|
}
|
|
}
|
|
|
|
// 4 empty foundations
|
|
#expect(snapshot.foundations.count == 4)
|
|
#expect(snapshot.foundations.allSatisfy { $0.isEmpty })
|
|
|
|
// Remaining 24 cards in stock
|
|
#expect(snapshot.stock.count == 24)
|
|
#expect(snapshot.waste.isEmpty)
|
|
}
|
|
|
|
@Test("Can move ace to empty foundation")
|
|
func aceToFoundation() {
|
|
let ace = Card(suit: .hearts, rank: .ace, isFaceUp: true)
|
|
var snapshot = emptySnapshot()
|
|
snapshot.tableaus[0] = [ace]
|
|
|
|
#expect(rules.canMove(cards: [ace], from: .tableau(0), to: .foundation(0), state: snapshot))
|
|
}
|
|
|
|
@Test("Cannot move non-ace to empty foundation")
|
|
func nonAceToEmptyFoundation() {
|
|
let two = Card(suit: .hearts, rank: .two, isFaceUp: true)
|
|
var snapshot = emptySnapshot()
|
|
snapshot.tableaus[0] = [two]
|
|
|
|
#expect(!rules.canMove(cards: [two], from: .tableau(0), to: .foundation(0), state: snapshot))
|
|
}
|
|
|
|
@Test("Can stack alternating colors descending on tableau")
|
|
func tableauStacking() {
|
|
let blackSix = Card(suit: .spades, rank: .six, isFaceUp: true)
|
|
let redFive = Card(suit: .hearts, rank: .five, isFaceUp: true)
|
|
var snapshot = emptySnapshot()
|
|
snapshot.tableaus[0] = [blackSix]
|
|
snapshot.tableaus[1] = [redFive]
|
|
|
|
#expect(rules.canMove(cards: [redFive], from: .tableau(1), to: .tableau(0), state: snapshot))
|
|
}
|
|
|
|
@Test("Cannot stack same color on tableau")
|
|
func sameColorBlocked() {
|
|
let blackSix = Card(suit: .spades, rank: .six, isFaceUp: true)
|
|
let blackFive = Card(suit: .clubs, rank: .five, isFaceUp: true)
|
|
var snapshot = emptySnapshot()
|
|
snapshot.tableaus[0] = [blackSix]
|
|
snapshot.tableaus[1] = [blackFive]
|
|
|
|
#expect(!rules.canMove(cards: [blackFive], from: .tableau(1), to: .tableau(0), state: snapshot))
|
|
}
|
|
|
|
@Test("Only king on empty tableau")
|
|
func kingOnEmptyTableau() {
|
|
let king = Card(suit: .hearts, rank: .king, isFaceUp: true)
|
|
let queen = Card(suit: .hearts, rank: .queen, isFaceUp: true)
|
|
var snapshot = emptySnapshot()
|
|
snapshot.tableaus[1] = [king]
|
|
snapshot.tableaus[2] = [queen]
|
|
|
|
#expect(rules.canMove(cards: [king], from: .tableau(1), to: .tableau(0), state: snapshot))
|
|
#expect(!rules.canMove(cards: [queen], from: .tableau(2), to: .tableau(0), state: snapshot))
|
|
}
|
|
|
|
@Test("Win detection")
|
|
func winDetection() {
|
|
var snapshot = emptySnapshot()
|
|
// Fill all foundations with 13 cards each
|
|
for i in 0..<4 {
|
|
let suit = Suit.allCases[i]
|
|
snapshot.foundations[i] = Rank.allCases.map { Card(suit: suit, rank: $0, isFaceUp: true) }
|
|
}
|
|
#expect(rules.isWon(state: snapshot))
|
|
}
|
|
|
|
@Test("Not won with incomplete foundations")
|
|
func notWon() {
|
|
let snapshot = emptySnapshot()
|
|
#expect(!rules.isWon(state: snapshot))
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func emptySnapshot() -> GameSnapshot {
|
|
GameSnapshot(
|
|
tableaus: Array(repeating: [], count: 7),
|
|
foundations: [[], [], [], []],
|
|
stock: [],
|
|
waste: [],
|
|
freeCells: [],
|
|
moves: 0,
|
|
score: 0
|
|
)
|
|
}
|
|
}
|