From d1bb5402fd06f321508f3f2a8ea83a91a5563315 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Sat, 25 Apr 2026 11:15:13 +0200 Subject: [PATCH 1/2] replace Mobius loop with MVVM (GameViewModel) and tighten architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game feature - Drop the Mobius update loop, effects, events, and effect handler. - Introduce GameViewModel: single source of truth for game state, exposes onModelChanged / onFlipCard / onNavigateToGameOver callbacks; owns flip-back and game-over DispatchWorkItems so they can be cancelled on viewDidDisappear. - GameVC becomes a thin wiring layer: loadView installs GameView, viewWillAppear wires viewModel.start(...), viewDidDisappear stops it. - GameView encapsulates the SingleCellTypeCollectionView internally; data source/delegate are init-injected. - MemoryDataSourceAndDelegate's closures are non-optional `let` so missing wiring is a compile error rather than silently ignored. - Extract grid-sizing math into testable CardGridLayout struct. Loading feature - LoadingVC now owns the view and wires LoadingViewModel callbacks in viewDidLoad (matching GameVC's pattern); init no longer threads view references through the view model. - LoadingViewModel exposes onPhaseChange / onNavigateToGame callbacks (no Diffuser dependency). - Drop dead Phase.initial NOOP case; logging-only branch removed. - Warn when prefetch completes after onNavigateToGame is cleared. Game-over flow - Thread the original GameConfiguration through GameOutcome so RootVC no longer reconstructs `GameConfiguration(level: outcome.level)`. - GameOverVC takes only an outcome; restart uses outcome.config. Game setup - GameSetupView is stateless: `startGameTapped` reads control values fresh and emits a GameConfiguration, no internal `var config`. Reusable cell support - Introduce CellProtocol with a default `cellIdentifier` via `String(describing: Self.self)`. SingleCellTypeCollectionView now constrains `Cell: UICollectionViewCell & CellProtocol`. - NSObject.className: replace force-unwrap with `String(describing: self)`. Polish - GameView.animateFlip warns when the target cell is off-screen. - GameOverView circular-button diameter named (no magic 80). - CardDuplicates exposes init(ordered:) so tests can build a deterministic deck without retry-shuffling 4096 times. - SingleCellTypeCollectionView.init(coder:) gets a descriptive fatalError. Tests - New unit tests: GameViewModel, GameView, CardGridLayout, CardSingles (Equatable/Hashable), LoadingViewModel (Phase.description, stop, retry), SingleCellTypeCollectionView (make / dequeue / cellForItemAt). - LoadingVCTests: viewDidDisappear → stop, retry button @objc target. - Replace force-casts in VC tests with XCTUnwrap / XCTFail with file:line. - Mobius-era tests removed. Tooling - codecov.yml: ignore SwiftIntroTests/** so test code does not count toward coverage measurements. - SwiftIntroTests/.swiftlint.yml: relax identifier_name min_length to 1 (test-only) so locals like `vc`, `cv`, `a`, `b` don't trip the linter. Co-Authored-By: Claude Opus 4.7 --- SwiftIntro.xcodeproj/project.pbxproj | 136 ++---- .../xcshareddata/swiftpm/Package.resolved | 54 +-- SwiftIntro/App/RootVC.swift | 3 +- .../Extensions/NSObject_Extension.swift | 5 +- SwiftIntro/Features/Game/GameVC.swift | 132 ++---- SwiftIntro/Features/Game/GameViewModel.swift | 268 +++++++++++ .../Features/Game/Logic/GameEffect.swift | 38 -- .../Game/Logic/GameEffectHandler.swift | 175 ------- .../Features/Game/Logic/GameEvent.swift | 20 - .../Features/Game/Logic/GameLogic.swift | 149 ------ SwiftIntro/Features/Game/Logic/GameLoop.swift | 103 ---- .../Features/Game/Model/State/GameModel.swift | 10 +- .../Game/Model/Values/CardDuplicates.swift | 14 +- .../Game/Model/Values/CardSingles.swift | 2 + .../Features/Game/View/CardCVCell.swift | 7 +- .../Features/Game/View/CardGridLayout.swift | 54 +++ SwiftIntro/Features/Game/View/GameView.swift | 42 +- .../View/MemoryDataSourceAndDelegate.swift | 78 ++-- SwiftIntro/Features/GameOver/GameOverVC.swift | 13 +- .../Features/GameOver/Model/GameOutcome.swift | 10 +- .../Features/GameOver/View/GameOverView.swift | 7 +- .../GameSetup/View/GameSetupView.swift | 35 +- SwiftIntro/Features/Loading/LoadingVC.swift | 29 +- .../Features/Loading/LoadingViewModel.swift | 63 ++- .../Features/Loading/View/LoadingView.swift | 2 - SwiftIntro/Views/CellProtocol.swift | 14 +- .../Views/SingleCellTypeCollectionView.swift | 70 +++ SwiftIntroTests/.swiftlint.yml | 10 +- SwiftIntroTests/App/RootVCTests.swift | 12 +- .../Dependencies/ImmediateClock.swift | 25 + .../Game/{Logic => }/GameModelTests.swift | 0 .../Features/Game/GameVCTests.swift | 227 ++++----- .../Features/Game/GameViewModelTests.swift | 383 +++++++++++++++ .../Game/Logic/GameEffectHandlerTests.swift | 247 ---------- .../Features/Game/Logic/GameEffectTests.swift | 125 ----- .../Features/Game/Logic/GameLogicTests.swift | 438 ------------------ .../Features/Game/Logic/GameLoopTests.swift | 202 -------- .../Game/View/CardGridLayoutTests.swift | 74 +++ .../Features/Game/View/GameViewTests.swift | 47 ++ .../MemoryDataSourceAndDelegateTests.swift | 84 ++-- .../Features/GameOver/GameOverVCTests.swift | 68 +-- .../GameOver/GameOverViewSnapshotTests.swift | 6 +- .../Features/Loading/LoadingVCTests.swift | 55 ++- .../Loading/LoadingViewModelTests.swift | 134 ++++++ SwiftIntroTests/Models/CardSinglesTests.swift | 67 +++ .../SingleCellTypeCollectionViewTests.swift | 112 +++++ codecov.yml | 6 + 47 files changed, 1749 insertions(+), 2106 deletions(-) create mode 100644 SwiftIntro/Features/Game/GameViewModel.swift delete mode 100644 SwiftIntro/Features/Game/Logic/GameEffect.swift delete mode 100644 SwiftIntro/Features/Game/Logic/GameEffectHandler.swift delete mode 100644 SwiftIntro/Features/Game/Logic/GameEvent.swift delete mode 100644 SwiftIntro/Features/Game/Logic/GameLogic.swift delete mode 100644 SwiftIntro/Features/Game/Logic/GameLoop.swift create mode 100644 SwiftIntro/Features/Game/View/CardGridLayout.swift create mode 100644 SwiftIntro/Views/SingleCellTypeCollectionView.swift create mode 100644 SwiftIntroTests/Dependencies/ImmediateClock.swift rename SwiftIntroTests/Features/Game/{Logic => }/GameModelTests.swift (100%) create mode 100644 SwiftIntroTests/Features/Game/GameViewModelTests.swift delete mode 100644 SwiftIntroTests/Features/Game/Logic/GameEffectHandlerTests.swift delete mode 100644 SwiftIntroTests/Features/Game/Logic/GameEffectTests.swift delete mode 100644 SwiftIntroTests/Features/Game/Logic/GameLogicTests.swift delete mode 100644 SwiftIntroTests/Features/Game/Logic/GameLoopTests.swift create mode 100644 SwiftIntroTests/Features/Game/View/CardGridLayoutTests.swift create mode 100644 SwiftIntroTests/Features/Game/View/GameViewTests.swift create mode 100644 SwiftIntroTests/Features/Loading/LoadingViewModelTests.swift create mode 100644 SwiftIntroTests/Models/CardSinglesTests.swift create mode 100644 SwiftIntroTests/Views/SingleCellTypeCollectionViewTests.swift diff --git a/SwiftIntro.xcodeproj/project.pbxproj b/SwiftIntro.xcodeproj/project.pbxproj index 449348f..1d8ed29 100644 --- a/SwiftIntro.xcodeproj/project.pbxproj +++ b/SwiftIntro.xcodeproj/project.pbxproj @@ -8,8 +8,8 @@ /* Begin PBXBuildFile section */ 45773A371D00C088002E6F56 /* GameSetupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45773A361D00C088002E6F56 /* GameSetupVC.swift */; }; + 4856F11B2F9A4BF800A89D8E /* SingleCellTypeCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4856F11A2F9A4BF700A89D8E /* SingleCellTypeCollectionView.swift */; }; 48D053AE2F8E355C006B3DEF /* CardSingles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48D053AD2F8E355A006B3DEF /* CardSingles.swift */; }; - 48D053B62F8E5500006B3DEF /* GameLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48D053B52F8E5500006B3DEF /* GameLoop.swift */; }; 48DEC8A52F90207100DEBF95 /* Loading.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 48DEC8A42F90207100DEBF95 /* Loading.xcstrings */; }; 48DEC8A72F90207700DEBF95 /* GameOver.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 48DEC8A62F90207700DEBF95 /* GameOver.xcstrings */; }; 48DEC8A92F90207D00DEBF95 /* Game.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 48DEC8A82F90207D00DEBF95 /* Game.xcstrings */; }; @@ -23,6 +23,7 @@ 7B21A5B41CFF5CAC002E5EE5 /* WikimediaClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B21A5B31CFF5CAC002E5EE5 /* WikimediaClient.swift */; }; 7B21A5B81CFF5E7B002E5EE5 /* GameVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B21A5B71CFF5E7B002E5EE5 /* GameVC.swift */; }; 7B21A5BB1CFF6281002E5EE5 /* MemoryDataSourceAndDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B21A5BA1CFF6281002E5EE5 /* MemoryDataSourceAndDelegate.swift */; }; + CGL000002CGL000002CGL00002 /* CardGridLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = CGL000001CGL000001CGL00001 /* CardGridLayout.swift */; }; 7B21A5C21CFF639D002E5EE5 /* CardCVCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B21A5C11CFF639D002E5EE5 /* CardCVCell.swift */; }; 7B21A5C51CFF6586002E5EE5 /* NSObject_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B21A5C41CFF6586002E5EE5 /* NSObject_Extension.swift */; }; 7B2F90AC1D00C88800613325 /* UIView_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2F90AB1D00C88800613325 /* UIView_Extension.swift */; }; @@ -47,42 +48,39 @@ BB200001BB200001BB200001 /* GameHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB100001BB100001BB100001 /* GameHeaderView.swift */; }; BBFE0003BBFE0003BBFE0003 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = BBFE0002BBFE0002BBFE0002 /* Kingfisher */; }; CC020002CC020002CC020002 /* GameSetup.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = CC020001CC020001CC020001 /* GameSetup.xcstrings */; }; - DFSR0003DFSR0003DFSR0003 /* Diffuser in Frameworks */ = {isa = PBXBuildFile; productRef = DFSR0002DFSR0002DFSR0002 /* Diffuser */; }; - EEFE0003EEFE0003EEFE0003 /* MobiusCore in Frameworks */ = {isa = PBXBuildFile; productRef = EEFE0002EEFE0002EEFE0002 /* MobiusCore */; }; - EEFE0007EEFE0007EEFE0007 /* MobiusCore in Frameworks */ = {isa = PBXBuildFile; productRef = EEFE0006EEFE0006EEFE0006 /* MobiusCore */; }; FF010002FF010002FF010002 /* GameModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF010001FF010001FF010001 /* GameModel.swift */; }; - FF020002FF020002FF020002 /* GameEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF020001FF020001FF020001 /* GameEvent.swift */; }; - FF030002FF030002FF030002 /* GameEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF030001FF030001FF030001 /* GameEffect.swift */; }; - FF040002FF040002FF040002 /* GameLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF040001FF040001FF040001 /* GameLogic.swift */; }; - FF050002FF050002FF050002 /* GameEffectHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF050001FF050001FF050001 /* GameEffectHandler.swift */; }; FF060002FF060002FF060002 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF060001FF060001FF060001 /* SceneDelegate.swift */; }; FF070002FF070002FF070002 /* Logger+SwiftIntro.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF070001FF070001FF070001 /* Logger+SwiftIntro.swift */; }; FF080002FF080002FF080002 /* DependencyInjection+Registration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF080001FF080001FF080001 /* DependencyInjection+Registration.swift */; }; FFFE0003FFFE0003FFFE0003 /* Factory in Frameworks */ = {isa = PBXBuildFile; productRef = FFFE0002FFFE0002FFFE0002 /* Factory */; }; + GVM00002GVM00002GVM00002 /* GameViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = GVM00001GVM00001GVM00001 /* GameViewModel.swift */; }; + GVMT0002GVMT0002GVMT0002 /* GameViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = GVMT0001GVMT0001GVMT0001 /* GameViewModelTests.swift */; }; + IMC00002IMC00002IMC00002 /* ImmediateClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = IMC00001IMC00001IMC00001 /* ImmediateClock.swift */; }; LD010002LD010002LD010002 /* LoadingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = LD010001LD010001LD010001 /* LoadingViewModel.swift */; }; PG000002PG000002PG000002 /* PreparedGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = PG000001PG000001PG000001 /* PreparedGame.swift */; }; RC000002RC000002RC000002 /* RootVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC000001RC000001RC000001 /* RootVC.swift */; }; RV000002RV000002RV000002 /* RootVCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RV000001RV000001RV000001 /* RootVCTests.swift */; }; SSST0003SSST0003SSST0003 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = SSST0002SSST0002SSST0002 /* SnapshotTesting */; }; - TA000002TA000002TA000002 /* GameLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TA000001TA000001TA000001 /* GameLogicTests.swift */; }; TB000002TB000002TB000002 /* LevelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TB000001TB000001TB000001 /* LevelTests.swift */; }; TC000002TC000002TC000002 /* WikimediaRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TC000001TC000001TC000001 /* WikimediaRouterTests.swift */; }; TD000002TD000002TD000002 /* GameModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TD000001TD000001TD000001 /* GameModelTests.swift */; }; - TY000002TY000002TY000002 /* GameEffectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TY000001TY000001TY000001 /* GameEffectTests.swift */; }; TE000002TE000002TE000002 /* CardDuplicatesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TE000001TE000001TE000001 /* CardDuplicatesTests.swift */; }; + CST000002CST000002CST00002 /* CardSinglesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CST000001CST000001CST00001 /* CardSinglesTests.swift */; }; TF000002TF000002TF000002 /* ArrayExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TF000001TF000001TF000001 /* ArrayExtensionTests.swift */; }; TH000002TH000002TH000002 /* NSObjectExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TH000001TH000001TH000001 /* NSObjectExtensionTests.swift */; }; TI000002TI000002TI000002 /* UIViewExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TI000001TI000001TI000001 /* UIViewExtensionTests.swift */; }; TJ000002TJ000002TJ000002 /* StringsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TJ000001TJ000001TJ000001 /* StringsTests.swift */; }; TK000002TK000002TK000002 /* HTTPClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TK000001TK000001TK000001 /* HTTPClientTests.swift */; }; - TL000002TL000002TL000002 /* GameEffectHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TL000001TL000001TL000001 /* GameEffectHandlerTests.swift */; }; - TM000002TM000002TM000002 /* GameLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TM000001TM000001TM000001 /* GameLoopTests.swift */; }; TN000002TN000002TN000002 /* WikimediaClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TN000001TN000001TN000001 /* WikimediaClientTests.swift */; }; TO000002TO000002TO000002 /* MemoryDataSourceAndDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TO000001TO000001TO000001 /* MemoryDataSourceAndDelegateTests.swift */; }; + CGLT00002CGLT00002CGLT0002 /* CardGridLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CGLT00001CGLT00001CGLT0001 /* CardGridLayoutTests.swift */; }; + GVWT0002GVWT0002GVWT00002 /* GameViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = GVWT0001GVWT0001GVWT00001 /* GameViewTests.swift */; }; TP000002TP000002TP000002 /* ImageCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TP000001TP000001TP000001 /* ImageCacheTests.swift */; }; TQ000002TQ000002TQ000002 /* LoadingVCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TQ000001TQ000001TQ000001 /* LoadingVCTests.swift */; }; + LVMT0002LVMT0002LVMT00002 /* LoadingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVMT0001LVMT0001LVMT00001 /* LoadingViewModelTests.swift */; }; TR000002TR000002TR000002 /* GameVCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TR000001TR000001TR000001 /* GameVCTests.swift */; }; TS010002TS010002TS010002 /* CircularButtonSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TS010001TS010001TS010001 /* CircularButtonSnapshotTests.swift */; }; + SCTT0002SCTT0002SCTT00002 /* SingleCellTypeCollectionViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SCTT0001SCTT0001SCTT00001 /* SingleCellTypeCollectionViewTests.swift */; }; TS020002TS020002TS020002 /* GameHeaderViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TS020001TS020001TS020001 /* GameHeaderViewSnapshotTests.swift */; }; TS030002TS030002TS030002 /* LoadingViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TS030001TS030001TS030001 /* LoadingViewSnapshotTests.swift */; }; TS040002TS040002TS040002 /* GameOverViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TS040001TS040001TS040001 /* GameOverViewSnapshotTests.swift */; }; @@ -106,13 +104,13 @@ /* Begin PBXFileReference section */ 45773A361D00C088002E6F56 /* GameSetupVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameSetupVC.swift; sourceTree = ""; }; + 4856F11A2F9A4BF700A89D8E /* SingleCellTypeCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleCellTypeCollectionView.swift; sourceTree = ""; }; 48ABFE172F8ED04C00AC357E /* _typos.toml */ = {isa = PBXFileReference; lastKnownFileType = text; path = _typos.toml; sourceTree = ""; }; 48ABFE182F8ED04C00AC357E /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = ""; }; 48ABFE192F8ED04C00AC357E /* DESIGN.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DESIGN.md; sourceTree = ""; }; 48ABFE1A2F8ED04C00AC357E /* Justfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Justfile; sourceTree = ""; }; 48ABFE1B2F8ED04C00AC357E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 48D053AD2F8E355A006B3DEF /* CardSingles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardSingles.swift; sourceTree = ""; }; - 48D053B52F8E5500006B3DEF /* GameLoop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameLoop.swift; sourceTree = ""; }; 48DEC8A42F90207100DEBF95 /* Loading.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Loading.xcstrings; sourceTree = ""; }; 48DEC8A62F90207700DEBF95 /* GameOver.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = GameOver.xcstrings; sourceTree = ""; }; 48DEC8A82F90207D00DEBF95 /* Game.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Game.xcstrings; sourceTree = ""; }; @@ -126,6 +124,7 @@ 7B21A5B31CFF5CAC002E5EE5 /* WikimediaClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WikimediaClient.swift; sourceTree = ""; }; 7B21A5B71CFF5E7B002E5EE5 /* GameVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameVC.swift; sourceTree = ""; }; 7B21A5BA1CFF6281002E5EE5 /* MemoryDataSourceAndDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MemoryDataSourceAndDelegate.swift; sourceTree = ""; }; + CGL000001CGL000001CGL00001 /* CardGridLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardGridLayout.swift; sourceTree = ""; }; 7B21A5C11CFF639D002E5EE5 /* CardCVCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardCVCell.swift; sourceTree = ""; }; 7B21A5C41CFF6586002E5EE5 /* NSObject_Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSObject_Extension.swift; sourceTree = ""; }; 7B2F90AB1D00C88800613325 /* UIView_Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIView_Extension.swift; sourceTree = ""; }; @@ -151,37 +150,37 @@ BB100001BB100001BB100001 /* GameHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameHeaderView.swift; sourceTree = ""; }; CC020001CC020001CC020001 /* GameSetup.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = GameSetup.xcstrings; sourceTree = ""; }; FF010001FF010001FF010001 /* GameModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameModel.swift; sourceTree = ""; }; - FF020001FF020001FF020001 /* GameEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameEvent.swift; sourceTree = ""; }; - FF030001FF030001FF030001 /* GameEffect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameEffect.swift; sourceTree = ""; }; - FF040001FF040001FF040001 /* GameLogic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameLogic.swift; sourceTree = ""; }; - FF050001FF050001FF050001 /* GameEffectHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameEffectHandler.swift; sourceTree = ""; }; FF060001FF060001FF060001 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; FF070001FF070001FF070001 /* Logger+SwiftIntro.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Logger+SwiftIntro.swift"; sourceTree = ""; }; FF080001FF080001FF080001 /* DependencyInjection+Registration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DependencyInjection+Registration.swift"; sourceTree = ""; }; + GVM00001GVM00001GVM00001 /* GameViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewModel.swift; sourceTree = ""; }; + GVMT0001GVMT0001GVMT0001 /* GameViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewModelTests.swift; sourceTree = ""; }; + IMC00001IMC00001IMC00001 /* ImmediateClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmediateClock.swift; sourceTree = ""; }; LD010001LD010001LD010001 /* LoadingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewModel.swift; sourceTree = ""; }; PG000001PG000001PG000001 /* PreparedGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreparedGame.swift; sourceTree = ""; }; RC000001RC000001RC000001 /* RootVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootVC.swift; sourceTree = ""; }; RV000001RV000001RV000001 /* RootVCTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootVCTests.swift; sourceTree = ""; }; - TA000001TA000001TA000001 /* GameLogicTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameLogicTests.swift; sourceTree = ""; }; TB000001TB000001TB000001 /* LevelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LevelTests.swift; sourceTree = ""; }; TC000001TC000001TC000001 /* WikimediaRouterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WikimediaRouterTests.swift; sourceTree = ""; }; TD000001TD000001TD000001 /* GameModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameModelTests.swift; sourceTree = ""; }; - TY000001TY000001TY000001 /* GameEffectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameEffectTests.swift; sourceTree = ""; }; TE000001TE000001TE000001 /* CardDuplicatesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardDuplicatesTests.swift; sourceTree = ""; }; + CST000001CST000001CST00001 /* CardSinglesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardSinglesTests.swift; sourceTree = ""; }; TEST0002TEST0002TEST0002 /* SwiftIntroTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftIntroTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; TF000001TF000001TF000001 /* ArrayExtensionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArrayExtensionTests.swift; sourceTree = ""; }; TH000001TH000001TH000001 /* NSObjectExtensionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSObjectExtensionTests.swift; sourceTree = ""; }; TI000001TI000001TI000001 /* UIViewExtensionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewExtensionTests.swift; sourceTree = ""; }; TJ000001TJ000001TJ000001 /* StringsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringsTests.swift; sourceTree = ""; }; TK000001TK000001TK000001 /* HTTPClientTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPClientTests.swift; sourceTree = ""; }; - TL000001TL000001TL000001 /* GameEffectHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameEffectHandlerTests.swift; sourceTree = ""; }; - TM000001TM000001TM000001 /* GameLoopTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameLoopTests.swift; sourceTree = ""; }; TN000001TN000001TN000001 /* WikimediaClientTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WikimediaClientTests.swift; sourceTree = ""; }; TO000001TO000001TO000001 /* MemoryDataSourceAndDelegateTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MemoryDataSourceAndDelegateTests.swift; sourceTree = ""; }; + CGLT00001CGLT00001CGLT0001 /* CardGridLayoutTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardGridLayoutTests.swift; sourceTree = ""; }; + GVWT0001GVWT0001GVWT00001 /* GameViewTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameViewTests.swift; sourceTree = ""; }; TP000001TP000001TP000001 /* ImageCacheTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCacheTests.swift; sourceTree = ""; }; TQ000001TQ000001TQ000001 /* LoadingVCTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadingVCTests.swift; sourceTree = ""; }; + LVMT0001LVMT0001LVMT00001 /* LoadingViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadingViewModelTests.swift; sourceTree = ""; }; TR000001TR000001TR000001 /* GameVCTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameVCTests.swift; sourceTree = ""; }; TS010001TS010001TS010001 /* CircularButtonSnapshotTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularButtonSnapshotTests.swift; sourceTree = ""; }; + SCTT0001SCTT0001SCTT00001 /* SingleCellTypeCollectionViewTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingleCellTypeCollectionViewTests.swift; sourceTree = ""; }; TS020001TS020001TS020001 /* GameHeaderViewSnapshotTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameHeaderViewSnapshotTests.swift; sourceTree = ""; }; TS030001TS030001TS030001 /* LoadingViewSnapshotTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadingViewSnapshotTests.swift; sourceTree = ""; }; TS040001TS040001TS040001 /* GameOverViewSnapshotTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameOverViewSnapshotTests.swift; sourceTree = ""; }; @@ -198,15 +197,12 @@ isa = PBXFrameworksBuildPhase; files = ( BBFE0003BBFE0003BBFE0003 /* Kingfisher in Frameworks */, - DFSR0003DFSR0003DFSR0003 /* Diffuser in Frameworks */, - EEFE0003EEFE0003EEFE0003 /* MobiusCore in Frameworks */, FFFE0003FFFE0003FFFE0003 /* Factory in Frameworks */, ); }; TEST0004TEST0004TEST0004 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( - EEFE0007EEFE0007EEFE0007 /* MobiusCore in Frameworks */, SSST0003SSST0003SSST0003 /* SnapshotTesting in Frameworks */, ); }; @@ -239,6 +235,7 @@ 48D053AC2F8E2775006B3DEF /* Views */ = { isa = PBXGroup; children = ( + 4856F11A2F9A4BF700A89D8E /* SingleCellTypeCollectionView.swift */, AA100001AA100001AA100001 /* CircularButton.swift */, 7B4497F91D189C3400E080C7 /* CellProtocol.swift */, ); @@ -253,22 +250,11 @@ BB100001BB100001BB100001 /* GameHeaderView.swift */, 7B21A5C11CFF639D002E5EE5 /* CardCVCell.swift */, 7B21A5BA1CFF6281002E5EE5 /* MemoryDataSourceAndDelegate.swift */, + CGL000001CGL000001CGL00001 /* CardGridLayout.swift */, ); path = View; sourceTree = ""; }; - 48D053B02F8E49A5006B3DEF /* Logic */ = { - isa = PBXGroup; - children = ( - 48D053B52F8E5500006B3DEF /* GameLoop.swift */, - FF020001FF020001FF020001 /* GameEvent.swift */, - FF030001FF030001FF030001 /* GameEffect.swift */, - FF050001FF050001FF050001 /* GameEffectHandler.swift */, - FF040001FF040001FF040001 /* GameLogic.swift */, - ); - path = Logic; - sourceTree = ""; - }; 48D053B12F8E49AD006B3DEF /* Model */ = { isa = PBXGroup; children = ( @@ -409,9 +395,9 @@ isa = PBXGroup; children = ( 48D053B12F8E49AD006B3DEF /* Model */, - 48D053B02F8E49A5006B3DEF /* Logic */, 48D053AF2F8E4920006B3DEF /* View */, 7B21A5B71CFF5E7B002E5EE5 /* GameVC.swift */, + GVM00001GVM00001GVM00001 /* GameViewModel.swift */, ); path = Game; sourceTree = ""; @@ -484,6 +470,7 @@ TN000001TN000001TN000001 /* WikimediaClientTests.swift */, TC000001TC000001TC000001 /* WikimediaRouterTests.swift */, TP000001TP000001TP000001 /* ImageCacheTests.swift */, + IMC00001IMC00001IMC00001 /* ImmediateClock.swift */, ); path = Dependencies; sourceTree = ""; @@ -512,33 +499,24 @@ GRPGAM001GRPGAM001GRPGAM001 /* Game */ = { isa = PBXGroup; children = ( - GRPGAM002GRPGAM002GRPGAM002 /* Logic */, GRPGAM003GRPGAM003GRPGAM003 /* View */, TR000001TR000001TR000001 /* GameVCTests.swift */, - ); - path = Game; - sourceTree = ""; - }; - GRPGAM002GRPGAM002GRPGAM002 /* Logic */ = { - isa = PBXGroup; - children = ( - TA000001TA000001TA000001 /* GameLogicTests.swift */, + GVMT0001GVMT0001GVMT0001 /* GameViewModelTests.swift */, TD000001TD000001TD000001 /* GameModelTests.swift */, - TL000001TL000001TL000001 /* GameEffectHandlerTests.swift */, - TM000001TM000001TM000001 /* GameLoopTests.swift */, - TY000001TY000001TY000001 /* GameEffectTests.swift */, ); - path = Logic; + path = Game; sourceTree = ""; }; GRPGAM003GRPGAM003GRPGAM003 /* View */ = { isa = PBXGroup; children = ( TO000001TO000001TO000001 /* MemoryDataSourceAndDelegateTests.swift */, + CGLT00001CGLT00001CGLT0001 /* CardGridLayoutTests.swift */, TX000001TX000001TX000001 /* CardCVCellTests.swift */, TS070001TS070001TS070001 /* CardCVCellSnapshotTests.swift */, TS020001TS020001TS020001 /* GameHeaderViewSnapshotTests.swift */, TS060001TS060001TS060001 /* GameViewSnapshotTests.swift */, + GVWT0001GVWT0001GVWT00001 /* GameViewTests.swift */, ); path = View; sourceTree = ""; @@ -566,6 +544,7 @@ isa = PBXGroup; children = ( TQ000001TQ000001TQ000001 /* LoadingVCTests.swift */, + LVMT0001LVMT0001LVMT00001 /* LoadingViewModelTests.swift */, TS030001TS030001TS030001 /* LoadingViewSnapshotTests.swift */, ); path = Loading; @@ -575,6 +554,7 @@ isa = PBXGroup; children = ( TE000001TE000001TE000001 /* CardDuplicatesTests.swift */, + CST000001CST000001CST00001 /* CardSinglesTests.swift */, TB000001TB000001TB000001 /* LevelTests.swift */, ); path = Models; @@ -584,6 +564,7 @@ isa = PBXGroup; children = ( TS010001TS010001TS010001 /* CircularButtonSnapshotTests.swift */, + SCTT0001SCTT0001SCTT00001 /* SingleCellTypeCollectionViewTests.swift */, ); path = Views; sourceTree = ""; @@ -617,8 +598,6 @@ name = SwiftIntro; packageProductDependencies = ( BBFE0002BBFE0002BBFE0002 /* Kingfisher */, - DFSR0002DFSR0002DFSR0002 /* Diffuser */, - EEFE0002EEFE0002EEFE0002 /* MobiusCore */, FFFE0002FFFE0002FFFE0002 /* Factory */, ); productName = SwiftIntro; @@ -639,7 +618,6 @@ ); name = SwiftIntroTests; packageProductDependencies = ( - EEFE0006EEFE0006EEFE0006 /* MobiusCore */, SSST0002SSST0002SSST0002 /* SnapshotTesting */, ); productName = SwiftIntroTests; @@ -677,8 +655,6 @@ mainGroup = 7B75421A1CFF3F9E009B2629; packageReferences = ( BBFE0001BBFE0001BBFE0001 /* XCRemoteSwiftPackageReference "Kingfisher" */, - DFSR0001DFSR0001DFSR0001 /* XCRemoteSwiftPackageReference "diffuser" */, - EEFE0001EEFE0001EEFE0001 /* XCRemoteSwiftPackageReference "Mobius" */, FFFE0001FFFE0001FFFE0001 /* XCRemoteSwiftPackageReference "Factory" */, SSST0001SSST0001SSST0001 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, ); @@ -718,12 +694,14 @@ AA040002AA040002AA040002 /* GameOverView.swift in Sources */, 48DEC8B92F90C9D700DEBF95 /* WikimediaClientProtocol.swift in Sources */, 7B4497FA1D189C3400E080C7 /* CellProtocol.swift in Sources */, - 48D053B62F8E5500006B3DEF /* GameLoop.swift in Sources */, + GVM00002GVM00002GVM00002 /* GameViewModel.swift in Sources */, 7B8608621D15993400037D36 /* UIButton_Extension.swift in Sources */, + 4856F11B2F9A4BF800A89D8E /* SingleCellTypeCollectionView.swift in Sources */, 7B21A5B01CFF5C17002E5EE5 /* App.swift in Sources */, 7B4497EF1D189BA100E080C7 /* CardDuplicates.swift in Sources */, PG000002PG000002PG000002 /* PreparedGame.swift in Sources */, 7B21A5BB1CFF6281002E5EE5 /* MemoryDataSourceAndDelegate.swift in Sources */, + CGL000002CGL000002CGL00002 /* CardGridLayout.swift in Sources */, 7B2F90AC1D00C88800613325 /* UIView_Extension.swift in Sources */, 7BADD6AD1D16DE8C005EEBE3 /* LoadingVC.swift in Sources */, LD010002LD010002LD010002 /* LoadingViewModel.swift in Sources */, @@ -747,11 +725,7 @@ 7B21A5B81CFF5E7B002E5EE5 /* GameVC.swift in Sources */, 7B8608661D15A1DB00037D36 /* Array_Extension.swift in Sources */, FF010002FF010002FF010002 /* GameModel.swift in Sources */, - FF020002FF020002FF020002 /* GameEvent.swift in Sources */, 48DEC8C02F90C9F000DEBF95 /* HTTPClient.swift in Sources */, - FF030002FF030002FF030002 /* GameEffect.swift in Sources */, - FF040002FF040002FF040002 /* GameLogic.swift in Sources */, - FF050002FF050002FF050002 /* GameEffectHandler.swift in Sources */, FF060002FF060002FF060002 /* SceneDelegate.swift in Sources */, FF070002FF070002FF070002 /* Logger+SwiftIntro.swift in Sources */, ); @@ -759,23 +733,25 @@ TEST0003TEST0003TEST0003 /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( - TA000002TA000002TA000002 /* GameLogicTests.swift in Sources */, TB000002TB000002TB000002 /* LevelTests.swift in Sources */, TC000002TC000002TC000002 /* WikimediaRouterTests.swift in Sources */, TD000002TD000002TD000002 /* GameModelTests.swift in Sources */, - TY000002TY000002TY000002 /* GameEffectTests.swift in Sources */, + GVMT0002GVMT0002GVMT0002 /* GameViewModelTests.swift in Sources */, + IMC00002IMC00002IMC00002 /* ImmediateClock.swift in Sources */, TE000002TE000002TE000002 /* CardDuplicatesTests.swift in Sources */, + CST000002CST000002CST00002 /* CardSinglesTests.swift in Sources */, TF000002TF000002TF000002 /* ArrayExtensionTests.swift in Sources */, TH000002TH000002TH000002 /* NSObjectExtensionTests.swift in Sources */, TI000002TI000002TI000002 /* UIViewExtensionTests.swift in Sources */, TJ000002TJ000002TJ000002 /* StringsTests.swift in Sources */, TK000002TK000002TK000002 /* HTTPClientTests.swift in Sources */, - TL000002TL000002TL000002 /* GameEffectHandlerTests.swift in Sources */, - TM000002TM000002TM000002 /* GameLoopTests.swift in Sources */, TN000002TN000002TN000002 /* WikimediaClientTests.swift in Sources */, TO000002TO000002TO000002 /* MemoryDataSourceAndDelegateTests.swift in Sources */, + CGLT00002CGLT00002CGLT0002 /* CardGridLayoutTests.swift in Sources */, + GVWT0002GVWT0002GVWT00002 /* GameViewTests.swift in Sources */, TP000002TP000002TP000002 /* ImageCacheTests.swift in Sources */, TQ000002TQ000002TQ000002 /* LoadingVCTests.swift in Sources */, + LVMT0002LVMT0002LVMT00002 /* LoadingViewModelTests.swift in Sources */, TR000002TR000002TR000002 /* GameVCTests.swift in Sources */, TU000002TU000002TU000002 /* GameOverVCTests.swift in Sources */, TV000002TV000002TV000002 /* GameSetupVCTests.swift in Sources */, @@ -783,6 +759,7 @@ TW000002TW000002TW000002 /* GameSetupViewTests.swift in Sources */, TX000002TX000002TX000002 /* CardCVCellTests.swift in Sources */, TS010002TS010002TS010002 /* CircularButtonSnapshotTests.swift in Sources */, + SCTT0002SCTT0002SCTT00002 /* SingleCellTypeCollectionViewTests.swift in Sources */, TS020002TS020002TS020002 /* GameHeaderViewSnapshotTests.swift in Sources */, TS030002TS030002TS030002 /* LoadingViewSnapshotTests.swift in Sources */, TS040002TS040002TS040002 /* GameOverViewSnapshotTests.swift in Sources */, @@ -1029,22 +1006,6 @@ version = 7.12.0; }; }; - DFSR0001DFSR0001DFSR0001 /* XCRemoteSwiftPackageReference "diffuser" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/spotify/diffuser"; - requirement = { - kind = revision; - revision = fea166a5c366209e68bfd957b18780e7abba522c; - }; - }; - EEFE0001EEFE0001EEFE0001 /* XCRemoteSwiftPackageReference "Mobius" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/spotify/Mobius.swift"; - requirement = { - kind = revision; - revision = 74baa7e07b86ae4c2673204a92230db397b8a6ae; - }; - }; FFFE0001FFFE0001FFFE0001 /* XCRemoteSwiftPackageReference "Factory" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/hmlongco/Factory.git"; @@ -1069,21 +1030,6 @@ package = BBFE0001BBFE0001BBFE0001 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; - DFSR0002DFSR0002DFSR0002 /* Diffuser */ = { - isa = XCSwiftPackageProductDependency; - package = DFSR0001DFSR0001DFSR0001 /* XCRemoteSwiftPackageReference "diffuser" */; - productName = Diffuser; - }; - EEFE0002EEFE0002EEFE0002 /* MobiusCore */ = { - isa = XCSwiftPackageProductDependency; - package = EEFE0001EEFE0001EEFE0001 /* XCRemoteSwiftPackageReference "Mobius" */; - productName = MobiusCore; - }; - EEFE0006EEFE0006EEFE0006 /* MobiusCore */ = { - isa = XCSwiftPackageProductDependency; - package = EEFE0001EEFE0001EEFE0001 /* XCRemoteSwiftPackageReference "Mobius" */; - productName = MobiusCore; - }; FFFE0002FFFE0002FFFE0002 /* Factory */ = { isa = XCSwiftPackageProductDependency; package = FFFE0001FFFE0001FFFE0001 /* XCRemoteSwiftPackageReference "Factory" */; diff --git a/SwiftIntro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftIntro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ed8a7be..8e76019 100644 --- a/SwiftIntro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftIntro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,32 +1,6 @@ { - "originHash" : "5f26bab62909d0aeab94329a0156b41b6c704a571c8d5aea62ee4e4bbf9b952c", + "originHash" : "ca1673c841012301a14fe978bb078a4fc2dfcd89ef6265681cc8848f1dac2171", "pins" : [ - { - "identity" : "cwlcatchexception", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlCatchException.git", - "state" : { - "revision" : "07b2ba21d361c223e25e3c1e924288742923f08c", - "version" : "2.2.1" - } - }, - { - "identity" : "cwlpreconditiontesting", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", - "state" : { - "revision" : "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071", - "version" : "2.2.2" - } - }, - { - "identity" : "diffuser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/spotify/diffuser", - "state" : { - "revision" : "fea166a5c366209e68bfd957b18780e7abba522c" - } - }, { "identity" : "factory", "kind" : "remoteSourceControl", @@ -45,32 +19,6 @@ "version" : "7.12.0" } }, - { - "identity" : "mobius.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/spotify/Mobius.swift", - "state" : { - "revision" : "74baa7e07b86ae4c2673204a92230db397b8a6ae" - } - }, - { - "identity" : "nimble", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Quick/Nimble", - "state" : { - "revision" : "1f3bde57bde12f5e7b07909848c071e9b73d6edc", - "version" : "10.0.0" - } - }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "bb436421f57269fbcfe7360735985321585a86e5", - "version" : "0.10.1" - } - }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", diff --git a/SwiftIntro/App/RootVC.swift b/SwiftIntro/App/RootVC.swift index e41413c..f4192c4 100644 --- a/SwiftIntro/App/RootVC.swift +++ b/SwiftIntro/App/RootVC.swift @@ -73,8 +73,7 @@ extension RootVC: GameNavigatorProtocol { /// Pushes `GameOverVC` after the final flip animation completes. func navigateToGameOver(outcome: GameOutcome) { logNav.info("Pushing GameOverVC — clicks: \(outcome.clickCount), level: \(outcome.level)") - let config = GameConfiguration(level: outcome.level) - let gameOverVC = GameOverVC(config: config, outcome: outcome) + let gameOverVC = GameOverVC(outcome: outcome) gameOverVC.navigator = self pushViewController(gameOverVC, animated: true) } diff --git a/SwiftIntro/Extensions/NSObject_Extension.swift b/SwiftIntro/Extensions/NSObject_Extension.swift index 594983b..1ffa5c0 100644 --- a/SwiftIntro/Extensions/NSObject_Extension.swift +++ b/SwiftIntro/Extensions/NSObject_Extension.swift @@ -9,11 +9,10 @@ import Foundation extension NSObject { - /// The unqualified class name, derived by stripping the module prefix from `NSStringFromClass`. + /// The unqualified class name, without the module prefix. /// /// For example, `SwiftIntro.CardCVCell` becomes `"CardCVCell"`. - /// Used as a stable reuse identifier for collection view cells via `CellProtocol`. static var className: String { - NSStringFromClass(self).components(separatedBy: ".").last! + String(describing: self) } } diff --git a/SwiftIntro/Features/Game/GameVC.swift b/SwiftIntro/Features/Game/GameVC.swift index 7c2c9fb..ed69c59 100644 --- a/SwiftIntro/Features/Game/GameVC.swift +++ b/SwiftIntro/Features/Game/GameVC.swift @@ -5,8 +5,6 @@ // Copyright © 2016-2026 SwiftIntro. All rights reserved. // -import Factory -import MobiusCore import UIKit // MARK: - GameNavigatorProtocol @@ -24,38 +22,38 @@ protocol GameNavigatorProtocol: AnyObject { /// The game screen view controller. /// -/// `GameVC` is a pure view in the Mobius sense — it implements `Connectable` to -/// render `GameModel` and dispatch `GameEvent`s, but owns no loop infrastructure. -/// The `MobiusController` and `GameEffectHandler` live inside `GameLoop`. +/// `GameVC` is a thin MVVM view controller — it installs `GameView`, wires the +/// data source closures to `GameViewModel`, and forwards lifecycle events. All +/// game state and logic live in the view model. final class GameVC: UIViewController { // MARK: Properties - /// Injected image cache — used to check whether card images are ready before - /// allowing cell configuration to proceed. - @Injected(\.imageCache) private var imageCache - - /// Owns the Mobius loop for this game session. Update and query operations are - /// forwarded through here so `GameVC` stays loop-infrastructure-free. - private let loop: GameLoop + /// Holds all game state and logic for this session. + private let viewModel: GameViewModel /// The root view; installed via `loadView()`. - private let gameView = GameView() + private lazy var gameView = GameView( + collectionViewDataSource: dataSourceAndDelegate, + collectionViewDelegate: dataSourceAndDelegate + ) - /// The UIKit data source and delegate — sized from `loop.level` in `init`. + /// The UIKit data source and delegate — sized from `viewModel.level` in `init`. private let dataSourceAndDelegate: MemoryDataSourceAndDelegate - /// Wired by the presenting controller (e.g. `GameSetupVC`) before the push. + /// Wired by the presenting controller (e.g. `RootVC`) before the push. weak var navigator: GameNavigatorProtocol? // MARK: Inits init(_ game: PreparedGame) { - let cardModels = game.cards.memoryCards.map(CardModel.init) - let loop = GameLoop(initialModel: GameModel(cards: cardModels, level: game.config.level)) - self.loop = loop + let viewModel = GameViewModel(game) + self.viewModel = viewModel dataSourceAndDelegate = MemoryDataSourceAndDelegate( - rows: loop.level.rowCount, - columns: loop.level.columnCount + rows: viewModel.level.rowCount, + columns: viewModel.level.columnCount, + canSelectCard: { index in viewModel.canSelectCard(at: index) }, + configureCell: { cell, index in viewModel.configureCell(cell, at: index) }, + onCardTapped: { index in viewModel.cardTapped(at: index) } ) super.init(nibName: nil, bundle: nil) } @@ -74,86 +72,42 @@ extension GameVC { view = gameView } - override func viewDidLoad() { - super.viewDidLoad() - // Logger interpolation is @autoclosure → closure context; compiler needs self. - // swiftformat:disable:next redundantSelf - logGame.notice("Game started — level: \(self.loop.level.debugDescription)") - setupCollectionView() - setupLoop() + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.start( + onModelChanged: { [weak self] model in + self?.gameView.render(model) + }, + onFlipCard: { [weak self] index, isFaceUp in + self?.animateFlip(at: index, isFaceUp: isFaceUp) + }, + onNavigateToGameOver: { [weak self] outcome in + self?.navigator?.navigateToGameOver(outcome: outcome) + } + ) } - /// Stops the loop and cancels any pending timers when the screen leaves the hierarchy. + /// Stops the view model — cancels pending timers and clears callbacks. override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - logGame.debug("GameVC disappeared — stopping Mobius loop") - loop.stop() - } -} - -// MARK: - Connectable - -extension GameVC: Connectable { - typealias Input = GameModel - typealias Output = GameEvent - - /// Called by `MobiusController` (via `GameLoop.start`) when the view connects. - /// - /// - Parameter consumer: Dispatch closure — call it with a `GameEvent` to inject - /// input into the loop (e.g. when a card is tapped). - /// - Returns: A `Connection` whose `acceptClosure` renders each new model - /// and whose `disposeClosure` cleans up the tap handler on disconnect. - func connect(_ consumer: @escaping (GameEvent) -> Void) -> Connection { - logGame.debug("GameVC connecting to Mobius loop — wiring card-tap dispatch") - dataSourceAndDelegate.onCardTapped = { consumer(.cardTapped(index: $0)) } - return Connection( - acceptClosure: { [weak self] model in - guard let self else { return } - loop.update(with: model) - gameView.render(model) - }, - disposeClosure: { [weak self] in - logGame.debug("GameVC disconnecting from Mobius loop — removing card-tap handler") - self?.dataSourceAndDelegate.onCardTapped = nil - } - ) + logGame.debug("GameVC disappeared — stopping view model") + viewModel.stop() } } // MARK: - Private private extension GameVC { - /// Wires the effect handler's UIKit dependencies and starts the Mobius loop. - func setupLoop() { - loop.start( - view: self, - collectionView: gameView.collectionView, - onNavigateToGameOver: { [weak self] outcome in - self?.navigator?.navigateToGameOver(outcome: outcome) - } + /// Looks up the cell at the given flat index and plays the flip animation. + func animateFlip( + at flatIndex: Int, + isFaceUp: Bool + ) { + let indexPath = IndexPath( + item: flatIndex % viewModel.level.columnCount, + section: flatIndex / viewModel.level.columnCount ) - } - - /// Assigns the data source and delegate, registers the cell class, and wires closures. - func setupCollectionView() { - gameView.collectionView.dataSource = dataSourceAndDelegate - gameView.collectionView.delegate = dataSourceAndDelegate - gameView.collectionView.register( - CardCVCell.self, - forCellWithReuseIdentifier: CardCVCell.cellIdentifier - ) - wireDataSourceClosures() - } - /// Connects the data source's query closures to the loop so it stays decoupled from `GameVC`. - func wireDataSourceClosures() { - dataSourceAndDelegate.canSelectCard = { [weak self] index in - guard let self else { return false } - return loop.canSelectCard(at: index) - } - dataSourceAndDelegate.configureCell = { [weak self] cell, index in - guard let self else { return } - loop.configureCell(cell, at: index) - } + gameView.animateFlip(at: indexPath, isFaceUp: isFaceUp) } } diff --git a/SwiftIntro/Features/Game/GameViewModel.swift b/SwiftIntro/Features/Game/GameViewModel.swift new file mode 100644 index 0000000..6d810d2 --- /dev/null +++ b/SwiftIntro/Features/Game/GameViewModel.swift @@ -0,0 +1,268 @@ +// +// GameViewModel.swift +// SwiftIntro +// +// Copyright © 2016-2026 SwiftIntro. All rights reserved. +// + +import Factory +import UIKit + +/// Owns and evolves the `GameModel` for a single game session. +/// +/// The view model is the single source of truth for the game's state. `GameVC` and +/// `GameView` only render snapshots delivered through callbacks — no game state lives +/// in the view layer. +/// +/// The flow is straightforward MVVM: +/// - The view calls `cardTapped(at:)` when the player taps a cell. +/// - The view model mutates its `model` and fires the appropriate callbacks +/// (`onModelChanged`, `onFlipCard`, `onNavigateToGameOver`). +/// - Delayed work (the 1-second flip-back timer and the post-win navigation delay) is +/// scheduled through the injected `Clock`, which tests can swap for `ImmediateClock`. +final class GameViewModel { + typealias OnModelChanged = (GameModel) -> Void + typealias OnFlipCard = (_ index: Int, _ faceUp: Bool) -> Void + typealias OnNavigateToGameOver = (GameOutcome) -> Void + + // MARK: - Dependencies + + /// Injected clock — `MainQueueClock` in production, `ImmediateClock` in tests. + @Injected(\.clock) private var clock + + // MARK: - State + + /// The complete game state. Mutating this triggers `onModelChanged`. + private var model: GameModel { + didSet { onModelChanged?(model) } + } + + /// Cancellable work item for the delayed flip-back timer. + /// Stored so it can be cancelled when the screen disappears or when a + /// subsequent non-match schedules a new flip-back. + private var flipBackWorkItem: DispatchWorkItem? + + /// Cancellable work item for the delayed game-over navigation. Stored so + /// `stop()` can cancel it if the screen disappears before it fires. + private var gameOverWorkItem: DispatchWorkItem? + + /// The player's configuration — threaded through into `GameOutcome` so the + /// restart flow does not have to reconstruct it from `Level` alone. + private let config: GameConfiguration + + /// The difficulty level for this session — exposed so `GameVC` can size the grid. + var level: Level { + config.level + } + + // MARK: - Callbacks + + /// Fires whenever the model changes — used by `GameView.render(_:)` to update + /// model-derived UI like the score label. + var onModelChanged: OnModelChanged? + + /// Fires when a single card needs to animate into the given face direction. + var onFlipCard: OnFlipCard? + + /// Fires after the player matches the final pair, once the closing flip has had + /// time to play out. + var onNavigateToGameOver: OnNavigateToGameOver? + + // MARK: - Init + + init(_ game: PreparedGame) { + let cards = game.cards.memoryCards.map(CardModel.init) + config = game.config + model = GameModel(cards: cards, level: game.config.level) + } +} + +// MARK: - Lifecycle + +extension GameViewModel { + /// Pushes the initial model out so the view can render its starting state. + func start( + onModelChanged: @escaping OnModelChanged, + onFlipCard: @escaping OnFlipCard, + onNavigateToGameOver: @escaping OnNavigateToGameOver + ) { + self.onFlipCard = onFlipCard + self.onModelChanged = onModelChanged + self.onNavigateToGameOver = onNavigateToGameOver + // swiftformat:disable:next redundantSelf + logGame.notice("Game started — level: \(self.level.debugDescription)") + onModelChanged(model) + } + + /// Cancels any pending timers and clears callbacks. Call from `viewDidDisappear`. + func stop() { + logGame.debug("GameViewModel stopping — cancelling timers and clearing callbacks") + flipBackWorkItem?.cancel() + flipBackWorkItem = nil + gameOverWorkItem?.cancel() + gameOverWorkItem = nil + onModelChanged = nil + onFlipCard = nil + onNavigateToGameOver = nil + } +} + +// MARK: - View queries + +extension GameViewModel { + /// Returns whether the card at `index` may currently be selected. + /// + /// Matched cards are permanently locked face-up and must not be tappable. + func canSelectCard(at index: Int) -> Bool { + !model.cards[index].isMatched + } + + /// Configures `cell` to reflect the current visual state of the card at `index`. + /// + /// Called from `willDisplay` in the data source, which fires whenever a cell + /// enters the visible area of the collection view. + func configureCell( + _ cell: CardCVCell, + at index: Int + ) { + cell.configure(with: model.cards[index]) + } +} + +// MARK: - User actions + +extension GameViewModel { + /// Handles a player tap on the card at `index`. + /// + /// Validates the tap, then delegates to `applyFlip` to mutate state and fire callbacks. + func cardTapped(at index: Int) { + logGame.debug("Card tapped at index \(index)") + guard index < model.cards.count else { + // swiftformat:disable:next redundantSelf + logGame.debug("Tap ignored — index \(index) is out of bounds (card count: \(self.model.cards.count))") + return + } + guard !model.cards[index].isFlipped, !model.cards[index].isMatched else { + logGame.debug("Tap ignored — card at index \(index) is already flipped or matched") + return + } + applyFlip(at: index) + } +} + +// MARK: - Private + +private extension GameViewModel { + /// Increments the click count, flips the tapped card face-up, then either stores it + /// as the pending card or evaluates the resulting pair. + /// + /// Mutations are applied to a local copy and assigned to `model` exactly once so + /// observers see a single coherent snapshot per tap rather than several intermediate + /// states (`clickCount` bump, card flip, pending-index change). + func applyFlip(at index: Int) { + var newModel = model + newModel.clickCount += 1 + newModel.cards[index].isFlipped = true + guard let pendingIndex = model.pendingCardIndex else { + newModel.pendingCardIndex = index + model = newModel + // swiftformat:disable:next redundantSelf + logGame.debug("First card flipped @\(index) — waiting for 2nd 🃏 (click #\(self.model.clickCount))") + onFlipCard?(index, true) + return + } + logGame + .debug("Second card of pair flipped at index \(index) — evaluating against pending index \(pendingIndex)") + newModel.pendingCardIndex = nil + model = newModel + evaluatePair(index: index, pendingIndex: pendingIndex) + } + + /// Compares the two face-up cards and either records a match or schedules the + /// non-matching pair to flip back after a short delay. + func evaluatePair( + index: Int, + pendingIndex: Int + ) { + let isMatchingPair = model.isCard(at: index, matchingCardAt: pendingIndex) + if isMatchingPair { + logGame.debug("Pair match! Indices \(pendingIndex) and \(index) are the same card") + handleMatch(index: index, pendingIndex: pendingIndex) + return + } + logGame.debug("No match — scheduling flip-back for indices \(pendingIndex) and \(index)") + onFlipCard?(index, true) + scheduleFlipBack(index1: pendingIndex, index2: index) + } + + /// Marks both cards matched and either flips the second card face-up (intermediate + /// match) or triggers the game-over flow (final pair). + func handleMatch( + index: Int, + pendingIndex: Int + ) { + model.cards[index].isMatched = true + model.cards[pendingIndex].isMatched = true + model.matches += 1 + // swiftformat:disable redundantSelf + logGame + .info( + "Match confirmed — \(self.model.matches)/\(self.model.totalPairs) pairs found (click #\(self.model.clickCount))" + ) + // swiftformat:enable redundantSelf + onFlipCard?(index, true) + guard model.matches == model.totalPairs else { return } + triggerGameOver() + } + + /// Builds the final outcome and schedules navigation so the closing flip animation + /// has time to play out before the screen swaps. + func triggerGameOver() { + // swiftformat:disable redundantSelf + logGame + .notice( + "Game over — all \(self.model.totalPairs) pairs matched in \(self.model.clickCount) clicks (level: \(self.level))" + ) + // swiftformat:enable redundantSelf + // Rebuild the deck from image URLs so the game-over screen can restart + // with the same images in a freshly shuffled order. + let cards = model.cards.map(\.card) + let outcome = GameOutcome( + config: config, + clickCount: model.clickCount, + cards: CardDuplicates(reshuffling: cards) + ) + // Short delay lets the final flip animation complete before navigating away. + gameOverWorkItem = clock.schedule(after: 1.0) { [weak self] in + logGame.debug("Firing onNavigateToGameOver callback") + self?.onNavigateToGameOver?(outcome) + self?.gameOverWorkItem = nil + } + } + + /// Schedules the two non-matching cards to flip back face-down after a 1-second delay. + /// Any previously-pending flip-back is cancelled first so rapid non-matches cannot + /// leave orphaned work items that `stop()` can no longer reach. + func scheduleFlipBack( + index1: Int, + index2: Int + ) { + flipBackWorkItem?.cancel() + flipBackWorkItem = clock.schedule(after: 1.0) { [weak self] in + self?.flipBackCards(index1: index1, index2: index2) + self?.flipBackWorkItem = nil + } + } + + /// Flips both cards face-down once the delayed timer fires. + func flipBackCards( + index1: Int, + index2: Int + ) { + logGame.debug("Flip-back timer fired — returning cards \(index1) and \(index2) face-down") + model.cards[index1].isFlipped = false + model.cards[index2].isFlipped = false + onFlipCard?(index1, false) + onFlipCard?(index2, false) + } +} diff --git a/SwiftIntro/Features/Game/Logic/GameEffect.swift b/SwiftIntro/Features/Game/Logic/GameEffect.swift deleted file mode 100644 index 05d821b..0000000 --- a/SwiftIntro/Features/Game/Logic/GameEffect.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// GameEffect.swift -// SwiftIntro -// -// Copyright © 2016-2026 SwiftIntro. All rights reserved. -// - -/// Side effects produced by the Mobius `update` function that require real-world work. -/// -/// Effects are handled by `GameEffectHandler`, which executes animations, timers, -/// and navigation. They are intentionally kept separate from state so that -/// `GameLogic.update` remains a pure function that is easy to unit-test. -enum GameEffect { - /// Animate a card at `index` to the given face-up or face-down state. - case flipCard(index: Int, faceUp: Bool) - - /// Schedule a 1-second delay, then dispatch `flipBackCards` to flip both - /// non-matching cards back to face-down. - case scheduleFlipBack(index1: Int, index2: Int) - - /// Navigate to the game-over screen after a short delay, carrying the result. - case navigateToGameOver(outcome: GameOutcome) -} - -// MARK: - CustomStringConvertible - -extension GameEffect: CustomStringConvertible { - var description: String { - switch self { - case let .flipCard(index, faceUp): - "flipCard(index: \(index), faceUp: \(faceUp))" - case let .scheduleFlipBack(index1, index2): - "scheduleFlipBack(index1: \(index1), index2: \(index2))" - case let .navigateToGameOver(outcome): - "navigateToGameOver(outcome: \(String(describing: outcome)))" - } - } -} diff --git a/SwiftIntro/Features/Game/Logic/GameEffectHandler.swift b/SwiftIntro/Features/Game/Logic/GameEffectHandler.swift deleted file mode 100644 index 29ddadd..0000000 --- a/SwiftIntro/Features/Game/Logic/GameEffectHandler.swift +++ /dev/null @@ -1,175 +0,0 @@ -// -// GameEffectHandler.swift -// SwiftIntro -// -// Copyright © 2016-2026 SwiftIntro. All rights reserved. -// - -import Factory -import MobiusCore -import UIKit - -/// Executes `GameEffect`s — all side effects (animations, timers, navigation) live here. -/// -/// `GameEffectHandler` implements `Connectable`, which is the -/// Mobius contract for the effect-handler side of the loop. The framework calls -/// `connect(_:)` once when the loop starts and provides a `dispatch` closure the -/// handler uses to feed new events (e.g. `.flipBackCards`) back into the loop. -/// -/// It also caches the latest `GameModel` so the UIKit data source can ask -/// "can this card be selected?" and "how should this cell look?" without -/// coupling the data source to the Mobius loop directly. -final class GameEffectHandler { - /// The collection view managed by the game screen. - /// Held weakly to avoid a retain cycle with the view controller. - weak var collectionView: UICollectionView? - - /// The difficulty level — used to convert flat card indices into `IndexPath` values. - let level: Level - - /// Called when the game is won, to trigger navigation. - var onNavigateToGameOver: ((GameOutcome) -> Void)? - - /// Injected clock — controls how delayed dispatches are scheduled. - /// `MainQueueClock` in production; `ImmediateClock` in tests. - @Injected(\.clock) private var clock - - /// Cancellable work item for the delayed flip-back timer. - /// Stored so it can be cancelled if the loop stops before the delay fires. - private var flipBackWorkItem: DispatchWorkItem? - - /// The most recent model snapshot, updated on every Mobius loop tick. - /// - /// Pre-seeded with the initial model so `configureCell` and `canSelectCard` - /// work on the very first `willDisplay` call, before the loop's first - /// asynchronous model delivery arrives. - private var currentModel: GameModel? - - /// - Parameters: - /// - initialModel: The starting model, used to configure cells on first display - /// before the Mobius loop delivers its first asynchronous model update. - init( - initialModel: GameModel - ) { - level = initialModel.level - currentModel = initialModel - } - - /// Stores the latest model so closure-based queries from the data source reflect - /// current game state (flip status, match status). - func update(with model: GameModel) { - currentModel = model - } - - /// Returns whether the card at `index` may be selected by the player. - /// - /// Matched cards are permanently locked face-up and must not be tappable. - func canSelectCard(at index: Int) -> Bool { - guard let model = currentModel else { return false } - return !model.cards[index].isMatched - } - - /// Configures `cell` to reflect the current visual state of the card at `index`. - /// - /// Called from `willDisplay` in the data source, which fires whenever a cell - /// enters the visible area of the collection view. - func configureCell( - _ cell: CardCVCell, - at index: Int - ) { - guard let model = currentModel else { return } - cell.configure(with: model.cards[index]) - } -} - -// MARK: - Connectable - -extension GameEffectHandler: Connectable { - typealias Input = GameEffect - typealias Output = GameEvent - - /// Called once by `MobiusController` when the loop starts. - /// - /// - Parameter consumer: The event dispatch closure — call it to send events - /// (e.g. `.flipBackCards`) back into the Mobius loop. - /// - Returns: A `Connection` whose `acceptClosure` handles each incoming effect - /// and whose `disposeClosure` cancels any pending timers on teardown. - func connect(_ consumer: @escaping (GameEvent) -> Void) -> Connection { - Connection( - acceptClosure: { [weak self] effect in - self?.handle(effect, dispatch: consumer) - }, - disposeClosure: { [weak self] in - self?.flipBackWorkItem?.cancel() - } - ) - } -} - -// MARK: - Effect handling - -private extension GameEffectHandler { - /// Routes an incoming effect to the appropriate handler. - func handle( - _ effect: GameEffect, - dispatch: @escaping (GameEvent) -> Void - ) { - logGame.debug("Handling effect: \(effect)") - switch effect { - case let .flipCard(index, faceUp): - handleFlipCard(index: index, faceUp: faceUp) - case let .scheduleFlipBack(index1, index2): - handleScheduleFlipBack(index1: index1, index2: index2, dispatch: dispatch) - case let .navigateToGameOver(outcome): - handleNavigateToGameOver(outcome: outcome) - } - } - - /// Animates the card at `index` to the given face direction on the main thread. - func handleFlipCard( - index: Int, - faceUp: Bool - ) { - logGame.debug("Animating card \(index) face \(faceUp ? "up" : "down")") - DispatchQueue.main.async { [weak self] in - guard let self, - let cell = collectionView?.cellForItem(at: indexPath(for: index)) as? CardCVCell - else { return } - cell.animateFlip(faceUp: faceUp) - } - } - - /// Schedules a `.flipBackCards` event after a 1-second delay using a cancellable `DispatchWorkItem`. - func handleScheduleFlipBack( - index1: Int, - index2: Int, - dispatch: @escaping (GameEvent) -> Void - ) { - logGame.debug("Scheduling flip-back for cards \(index1) and \(index2) after 1 s") - // The returned work item is stored so stop() can cancel it before the delay fires. - flipBackWorkItem = clock.schedule(after: 1.0) { - dispatch(.flipBackCards(index1: index1, index2: index2)) - } - } - - /// Fires `onNavigateToGameOver` after a short delay so the final flip animation finishes first. - func handleNavigateToGameOver(outcome: GameOutcome) { - logGame.info("Final flip complete — scheduling navigation to game-over screen after 1 s delay") - // Short delay lets the final flip animation complete before navigating away. - clock.schedule(after: 1.0) { [weak self] in - logGame.debug("Firing onNavigateToGameOver callback") - self?.onNavigateToGameOver?(outcome) - } - } - - /// Converts a row-major flat index into a `UICollectionView` `IndexPath`. - /// - /// The collection view uses sections for rows and items for columns: - /// `section = flatIndex / columnCount`, `item = flatIndex % columnCount`. - func indexPath(for flatIndex: Int) -> IndexPath { - IndexPath( - item: flatIndex % level.columnCount, - section: flatIndex / level.columnCount - ) - } -} diff --git a/SwiftIntro/Features/Game/Logic/GameEvent.swift b/SwiftIntro/Features/Game/Logic/GameEvent.swift deleted file mode 100644 index 8efa3fe..0000000 --- a/SwiftIntro/Features/Game/Logic/GameEvent.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// GameEvent.swift -// SwiftIntro -// -// Copyright © 2016-2026 SwiftIntro. All rights reserved. -// - -/// All inputs that can change the game state. -/// -/// Events flow into the Mobius loop's pure `update` function, which produces -/// a new `GameModel` and zero or more `GameEffect`s in response. -enum GameEvent { - /// The player tapped a card at the given flat (row-major) index. - case cardTapped(index: Int) - - /// The 1-second delay elapsed and two non-matching cards should flip back face-down. - /// - /// This event is dispatched by `GameEffectHandler` after `scheduleFlipBack` fires. - case flipBackCards(index1: Int, index2: Int) -} diff --git a/SwiftIntro/Features/Game/Logic/GameLogic.swift b/SwiftIntro/Features/Game/Logic/GameLogic.swift deleted file mode 100644 index 8ce9842..0000000 --- a/SwiftIntro/Features/Game/Logic/GameLogic.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// GameLogic.swift -// SwiftIntro -// -// Copyright © 2016-2026 SwiftIntro. All rights reserved. -// - -import MobiusCore - -/// Pure update function — the single source of truth for game-state evolution. -/// -/// `GameLogic` is a namespace (caseless enum) for the Mobius `update` function. -/// It is stateless and side-effect-free, making it straightforward to unit-test -/// without mocking any UIKit or networking infrastructure. -enum GameLogic { - /// Produces the next `GameModel` and any `GameEffect`s in response to a `GameEvent`. - /// - /// Called by the Mobius loop on its internal queue — never on the main thread. - /// - /// - Parameters: - /// - model: The current game state. - /// - event: The input that triggered this update. - /// - Returns: A `Next` value containing the updated model and/or effects to run. - static func update( - model: GameModel, - event: GameEvent - ) -> Next { - switch event { - case let .cardTapped(index): - handleCardTapped(index: index, model: model) - case let .flipBackCards(index1, index2): - handleFlipBack(index1: index1, index2: index2, model: model) - } - } -} - -// MARK: - Private helpers - -private extension GameLogic { - /// Validates the tap, then delegates to `applyFlip`. - static func handleCardTapped( - index: Int, - model: GameModel - ) -> Next { - logGame.debug("Card tapped at index \(index)") - guard index < model.cards.count else { - logGame.debug("Tap ignored — index \(index) is out of bounds (card count: \(model.cards.count))") - return .noChange - } - guard !model.cards[index].isFlipped, !model.cards[index].isMatched else { - logGame.debug("Tap ignored — card at index \(index) is already flipped or matched") - return .noChange - } - return applyFlip(index: index, model: model) - } - - /// Increments click count, flips the card, then routes to pair evaluation or stores it as pending. - static func applyFlip( - index: Int, - model: GameModel - ) -> Next { - var newModel = model - newModel.clickCount += 1 - newModel.cards[index].isFlipped = true - guard let pendingIndex = model.pendingCardIndex else { - logGame.debug("First card flipped at index \(index) — waiting for second 🃏 (click #\(newModel.clickCount))") - newModel.pendingCardIndex = index - return .next(newModel, effects: [.flipCard(index: index, faceUp: true)]) - } - logGame - .debug("Second card of pair flipped at index \(index) — evaluating against pending index \(pendingIndex)") - newModel.pendingCardIndex = nil - return evaluatePair(index: index, pendingIndex: pendingIndex, newModel: newModel) - } - - /// Compares the two face-up cards and returns either a match or a flip-back effect. - static func evaluatePair( - index: Int, - pendingIndex: Int, - newModel: GameModel - ) -> Next { - let isMatchingPair = newModel.isCard(at: index, matchingCardAt: pendingIndex) - if isMatchingPair { - logGame.debug("Pair match! Indices \(pendingIndex) and \(index) are the same card") - return handleMatch(index: index, pendingIndex: pendingIndex, newModel: newModel) - } - logGame.debug("No match — scheduling flip-back for indices \(pendingIndex) and \(index)") - return .next(newModel, effects: [ - .flipCard(index: index, faceUp: true), - .scheduleFlipBack(index1: pendingIndex, index2: index), - ]) - } - - /// Marks both cards matched; triggers game-over if all pairs are found. - static func handleMatch( - index: Int, - pendingIndex: Int, - newModel: GameModel - ) -> Next { - var newModel = newModel - newModel.cards[index].isMatched = true - newModel.cards[pendingIndex].isMatched = true - newModel.matches += 1 - logGame - .info( - "Match confirmed — \(newModel.matches)/\(newModel.totalPairs) pairs found (click #\(newModel.clickCount))" - ) - guard newModel.matches == newModel.totalPairs else { - return .next(newModel, effects: [.flipCard(index: index, faceUp: true)]) - } - return gameOverNext(index: index, newModel: newModel) - } - - /// Builds the game-over `Next` value, reconstructing the deck for a potential restart. - static func gameOverNext( - index: Int, - newModel: GameModel - ) -> Next { - logGame - .notice( - "Game over — all \(newModel.totalPairs) pairs matched in \(newModel.clickCount) clicks (level: \(newModel.level))" - ) - // Rebuild the deck from image URLs so the game-over screen can restart - // with the same images in a freshly shuffled order. - let cards = newModel.cards.map(\.card) - let outcome = GameOutcome( - level: newModel.level, - clickCount: newModel.clickCount, - cards: CardDuplicates(reshuffling: cards) - ) - return .next(newModel, effects: [.flipCard(index: index, faceUp: true), .navigateToGameOver(outcome: outcome)]) - } - - /// Flips both cards face-down after the delayed timer fires. - static func handleFlipBack( - index1: Int, - index2: Int, - model: GameModel - ) -> Next { - logGame.debug("Flip-back timer fired — returning cards \(index1) and \(index2) face-down") - var newModel = model - newModel.cards[index1].isFlipped = false - newModel.cards[index2].isFlipped = false - return .next(newModel, effects: [ - .flipCard(index: index1, faceUp: false), - .flipCard(index: index2, faceUp: false), - ]) - } -} diff --git a/SwiftIntro/Features/Game/Logic/GameLoop.swift b/SwiftIntro/Features/Game/Logic/GameLoop.swift deleted file mode 100644 index 91b5174..0000000 --- a/SwiftIntro/Features/Game/Logic/GameLoop.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// GameLoop.swift -// SwiftIntro -// -// Copyright © 2016-2026 SwiftIntro. All rights reserved. -// - -import MobiusCore -import UIKit - -/// Owns and manages the full Mobius loop for a single game session. -/// -/// `GameLoop` groups `MobiusController` and `GameEffectHandler` so that `GameVC` -/// can remain a pure view — it implements `Connectable` but knows nothing about -/// loop infrastructure. Create one `GameLoop` per game session and discard it when -/// the session ends. -final class GameLoop { - /// Handles all side effects: flip animations, the flip-back timer, and game-over navigation. - private let effectHandler: GameEffectHandler - - /// The Mobius loop controller — drives the `update → effect → event` cycle. - private let controller: MobiusController - - /// Builds the complete Mobius loop from the given initial model. - /// - /// `effectHandler` is pre-seeded with `initialModel` so cell configuration works - /// on the very first `willDisplay` call, before the loop's first async model delivery. - init(initialModel: GameModel) { - logGame.debug("GameLoop initializing — level: \(initialModel.level), pairs: \(initialModel.totalPairs)") - let effectHandler = GameEffectHandler(initialModel: initialModel) - self.effectHandler = effectHandler - controller = Mobius - .loop(update: GameLogic.update, effectHandler: effectHandler) - .makeController(from: initialModel) - } -} - -// MARK: Computed Properties - -extension GameLoop { - /// The difficulty level for this session — exposed so `GameVC` can size the grid - /// without storing `GameConfiguration` or `CardDuplicates` separately. - var level: Level { - effectHandler.level - } -} - -extension GameLoop { - /// Connects the view to the loop, wires the effect handler's UIKit dependencies, - /// and starts the Mobius loop. - /// - /// Call this from `viewDidLoad` after the collection view is ready. - /// - /// - Parameters: - /// - view: The `Connectable` view that renders `GameModel` and dispatches `GameEvent`s. - /// - collectionView: The card grid — used by the effect handler to find cells for flip animations. - /// - onNavigateToGameOver: Called on the main thread when the player wins. - func start( - view: View, - collectionView: UICollectionView, - onNavigateToGameOver: @escaping (GameOutcome) -> Void - ) where View.Input == GameModel, View.Output == GameEvent { - logGame.debug("GameLoop starting — connecting view and effect handler") - effectHandler.collectionView = collectionView - effectHandler.onNavigateToGameOver = onNavigateToGameOver - controller.connectView(view) - controller.start() - logGame.debug("Mobius loop is running") - } - - /// Stops the loop and disconnects the view, cancelling any pending timers. - /// - /// Idempotent — safe to call more than once; subsequent calls are no-ops. - /// Call this from `viewDidDisappear`. - func stop() { - guard controller.running else { - logGame.debug("GameLoop.stop() called but loop is not running — skipping") - return - } - logGame.debug("GameLoop stopping — cancelling pending timers and disconnecting view") - controller.stop() - controller.disconnectView() - } - - /// Forwards the latest model to the effect handler so `canSelectCard` and - /// `configureCell` reflect current game state. - func update(with model: GameModel) { - effectHandler.update(with: model) - } - - /// Returns whether the card at `index` may currently be selected. - func canSelectCard(at index: Int) -> Bool { - effectHandler.canSelectCard(at: index) - } - - /// Configures `cell` to match the current visual state of the card at `index`. - func configureCell( - _ cell: CardCVCell, - at index: Int - ) { - effectHandler.configureCell(cell, at: index) - } -} diff --git a/SwiftIntro/Features/Game/Model/State/GameModel.swift b/SwiftIntro/Features/Game/Model/State/GameModel.swift index 8d31970..44d191e 100644 --- a/SwiftIntro/Features/Game/Model/State/GameModel.swift +++ b/SwiftIntro/Features/Game/Model/State/GameModel.swift @@ -29,11 +29,6 @@ struct GameModel { /// When the player taps a second card, this index is compared to determine a match. var pendingCardIndex: Int? - /// Total number of pairs on the board. Game ends when `matches == totalPairs`. - var totalPairs: Int { - cards.count / 2 - } - init( cards: [CardModel], level: Level @@ -47,6 +42,11 @@ struct GameModel { } extension GameModel { + /// Total number of pairs on the board. Game ends when `matches == totalPairs`. + var totalPairs: Int { + cards.count / 2 + } + func isCard( at index: Int, matchingCardAt otherCardIndex: Int diff --git a/SwiftIntro/Features/Game/Model/Values/CardDuplicates.swift b/SwiftIntro/Features/Game/Model/Values/CardDuplicates.swift index fcd1de0..59fed7f 100644 --- a/SwiftIntro/Features/Game/Model/Values/CardDuplicates.swift +++ b/SwiftIntro/Features/Game/Model/Values/CardDuplicates.swift @@ -18,12 +18,12 @@ struct CardDuplicates { /// The shuffled, paired array of cards. Count is always even and non-zero. let memoryCards: [Card] - /// Designated initializer — asserts the pair invariant. + /// Designated initializer — asserts the pair invariant and preserves `cards` order. /// - /// Callers must have already shuffled `cards`; this is enforced by making the - /// initializer `private` so every construction path funnels through a public - /// init that shuffles first. - private init(validated cards: [Card]) { + /// Callers who need a shuffled deck should use `init(reshuffling:)` or + /// `init(singles:config:)`. This initializer is used directly by tests that + /// rely on a specific deterministic card order. + init(ordered cards: [Card]) { precondition(!cards.isEmpty, "Deck must not be empty") precondition(cards.count.isMultiple(of: 2), "Deck must contain an even number of cards") var frequency: [URL: Int] = [:] @@ -56,7 +56,7 @@ struct CardDuplicates { // Each image appears exactly twice — once per matching pair. var shuffled = chosen.flatMap { [$0, $0] } shuffled.shuffle() - self.init(validated: shuffled) + self.init(ordered: shuffled) } /// Rebuilds a deck from already-paired cards and re-shuffles them. @@ -65,7 +65,7 @@ struct CardDuplicates { init(reshuffling cards: [Card]) { var shuffled = cards shuffled.shuffle() - self.init(validated: shuffled) + self.init(ordered: shuffled) } } diff --git a/SwiftIntro/Features/Game/Model/Values/CardSingles.swift b/SwiftIntro/Features/Game/Model/Values/CardSingles.swift index e55ca65..61699f5 100644 --- a/SwiftIntro/Features/Game/Model/Values/CardSingles.swift +++ b/SwiftIntro/Features/Game/Model/Values/CardSingles.swift @@ -13,7 +13,9 @@ struct CardSingles: Hashable { /// The unique cards returned by the API, one per image URL. let cards: [Card] +} +extension CardSingles { static func == ( lhs: Self, rhs: Self diff --git a/SwiftIntro/Features/Game/View/CardCVCell.swift b/SwiftIntro/Features/Game/View/CardCVCell.swift index 9ce1bff..960576a 100644 --- a/SwiftIntro/Features/Game/View/CardCVCell.swift +++ b/SwiftIntro/Features/Game/View/CardCVCell.swift @@ -120,9 +120,4 @@ extension CardCVCell { // MARK: - CellProtocol -extension CardCVCell: CellProtocol { - /// Reuse identifier derived from the class name, matching the `register` call in `GameVC`. - static var cellIdentifier: String { - className - } -} +extension CardCVCell: CellProtocol {} diff --git a/SwiftIntro/Features/Game/View/CardGridLayout.swift b/SwiftIntro/Features/Game/View/CardGridLayout.swift new file mode 100644 index 0000000..a4fcf23 --- /dev/null +++ b/SwiftIntro/Features/Game/View/CardGridLayout.swift @@ -0,0 +1,54 @@ +// +// CardGridLayout.swift +// SwiftIntro +// +// Copyright © 2016-2026 SwiftIntro. All rights reserved. +// + +import UIKit + +/// Pure layout math for the card grid — computes square cell sizes that fit the +/// available space given row/column counts, section insets, and inter-item spacing. +/// +/// Kept free of `UICollectionView` so it can be unit-tested directly. +struct CardGridLayout { + let rows: Int + let columns: Int +} + +extension CardGridLayout { + /// Returns the largest square side length that fits all cards within `bounds`, + /// respecting both horizontal and vertical spacing from `flowLayout`. + func squareSide( + in bounds: CGSize, + flowLayout: UICollectionViewFlowLayout + ) -> CGFloat { + min( + maxHeight(in: bounds.height, flowLayout: flowLayout), + maxWidth(in: bounds.width, flowLayout: flowLayout) + ) + } + + /// Maximum card height that fits all rows within `availableHeight`. + func maxHeight( + in availableHeight: CGFloat, + flowLayout: UICollectionViewFlowLayout + ) -> CGFloat { + let rowCount = CGFloat(rows) + let spacing = flowLayout.sectionInset.top + flowLayout.sectionInset.bottom + + flowLayout.minimumLineSpacing * (rowCount - 1) + // `trunc` avoids sub-pixel cell sizes that cause layout rounding artefacts. + return trunc((availableHeight - spacing) / rowCount) + } + + /// Maximum card width that fits all columns within `availableWidth`. + func maxWidth( + in availableWidth: CGFloat, + flowLayout: UICollectionViewFlowLayout + ) -> CGFloat { + let columnCount = CGFloat(columns) + let spacing = flowLayout.sectionInset.left + flowLayout.sectionInset.right + + flowLayout.minimumInteritemSpacing * (columnCount - 1) + return trunc((availableWidth - spacing) / columnCount) + } +} diff --git a/SwiftIntro/Features/Game/View/GameView.swift b/SwiftIntro/Features/Game/View/GameView.swift index 20da34e..c3cb6fe 100644 --- a/SwiftIntro/Features/Game/View/GameView.swift +++ b/SwiftIntro/Features/Game/View/GameView.swift @@ -15,20 +15,19 @@ final class GameView: UIView { /// The header bar that displays the current match score. lazy var headerView = GameHeaderView() - /// The grid of card cells. Exposed so `GameVC` can assign its data source and delegate. - lazy var collectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - // Uniform spacing between rows and between columns. - layout.minimumLineSpacing = 8 - layout.minimumInteritemSpacing = 8 - let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) - cv.backgroundColor = .black - cv.translatesAutoresizingMaskIntoConstraints = false - return cv - }() + /// The grid of card cells. The data source and delegate are injected at init + /// time, so this stays encapsulated inside `GameView`. + private let collectionView: SingleCellTypeCollectionView - override init(frame: CGRect) { - super.init(frame: frame) + init( + collectionViewDataSource: (any UICollectionViewDataSource)? = nil, + collectionViewDelegate: (any UICollectionViewDelegate)? = nil + ) { + collectionView = SingleCellTypeCollectionView( + dataSource: collectionViewDataSource, + delegate: collectionViewDelegate + ) + super.init(frame: .zero) backgroundColor = .black setupLayout() } @@ -39,10 +38,21 @@ final class GameView: UIView { } } -// MARK: Internal - extension GameView { - /// Updates all model-driven UI — called by the Mobius loop `acceptClosure` on every model update. + func animateFlip( + at indexPath: IndexPath, + isFaceUp: Bool + ) { + guard let cell = collectionView.cellForItemAt(indexPath) else { + logGame.warning("animateFlip skipped — no visible cell at \(indexPath) (likely off-screen)") + return + } + cell.animateFlip(faceUp: isFaceUp) + } + + /// Updates all model-driven UI — invoked from `GameViewModel.onModelChanged` + /// whenever the view model emits a new `GameModel` snapshot (first via + /// `GameViewModel.start`, then on every subsequent state change). func render(_ model: GameModel) { headerView.scoreLabel.text = String( localized: .Game.pairsFoundUnformatted( diff --git a/SwiftIntro/Features/Game/View/MemoryDataSourceAndDelegate.swift b/SwiftIntro/Features/Game/View/MemoryDataSourceAndDelegate.swift index 8c289e9..9b88b4c 100644 --- a/SwiftIntro/Features/Game/View/MemoryDataSourceAndDelegate.swift +++ b/SwiftIntro/Features/Game/View/MemoryDataSourceAndDelegate.swift @@ -11,32 +11,46 @@ import UIKit /// Pure UIKit adapter — no game state or logic lives here. /// /// All decisions (can this cell be selected? how should it look?) are delegated -/// outward via closures, which are implemented by `GameEffectHandler`. +/// outward via closures, which are implemented by `GameViewModel`. /// /// `NSObject` is required because `UICollectionViewDataSource` and /// `UICollectionViewDelegate` are `@objc` protocols. final class MemoryDataSourceAndDelegate: NSObject { + typealias OnCardTapped = (Int) -> Void + typealias CanSelectCard = (Int) -> Bool + typealias ConfigureCell = (CardCVCell, Int) -> Void + /// Number of rows on the board (= number of collection view sections). private let numberOfRows: Int /// Number of columns on the board (= number of items per section). private let numberOfColumns: Int + /// Pure layout math for square card sizing. + private let gridLayout: CardGridLayout + /// Called when the player taps a valid, selectable card. Receives the flat index. - var onCardTapped: ((Int) -> Void)? + let onCardTapped: OnCardTapped /// Returns `true` if the card at the given flat index may be selected right now. - var canSelectCard: ((Int) -> Bool)? + let canSelectCard: CanSelectCard /// Configures the given cell to match the current visual state of the card at `index`. - var configureCell: ((CardCVCell, Int) -> Void)? + let configureCell: ConfigureCell init( rows: Int, - columns: Int + columns: Int, + canSelectCard: @escaping CanSelectCard, + configureCell: @escaping ConfigureCell, + onCardTapped: @escaping OnCardTapped ) { numberOfRows = rows numberOfColumns = columns + gridLayout = CardGridLayout(rows: rows, columns: columns) + self.canSelectCard = canSelectCard + self.configureCell = configureCell + self.onCardTapped = onCardTapped } } @@ -47,46 +61,6 @@ private extension MemoryDataSourceAndDelegate { func flatIndex(for indexPath: IndexPath) -> Int { indexPath.item + numberOfColumns * indexPath.section } - - /// Returns the square side length that fits all cards in the visible grid, - /// respecting both the horizontal and vertical spacing constraints. - func calculateCardSize( - _ flowLayout: UICollectionViewFlowLayout, - collectionView: UICollectionView - ) -> CGSize { - // Use the smaller of the two axis-maximum sizes so cards are always square - // and never overflow in either direction. - let side = min( - calculateMinimumHeight(flowLayout, collectionView: collectionView), - calculateMinimumWidth(flowLayout, collectionView: collectionView) - ) - return CGSize(width: side, height: side) - } - - /// Maximum card height that fits all rows in the available vertical space. - func calculateMinimumHeight( - _ flowLayout: UICollectionViewFlowLayout, - collectionView: UICollectionView - ) -> CGFloat { - let rows = CGFloat(numberOfRows) - // Total vertical space consumed by section insets and inter-row spacing. - let spacing = flowLayout.sectionInset.top + flowLayout.sectionInset.bottom - + flowLayout.minimumLineSpacing * (rows - 1) - // `trunc` avoids sub-pixel cell sizes that can cause layout rounding artefacts. - return trunc((collectionView.bounds.height - spacing) / rows) - } - - /// Maximum card width that fits all columns in the available horizontal space. - func calculateMinimumWidth( - _ flowLayout: UICollectionViewFlowLayout, - collectionView: UICollectionView - ) -> CGFloat { - let columns = CGFloat(numberOfColumns) - // Total horizontal space consumed by section insets and inter-column spacing. - let spacing = flowLayout.sectionInset.left + flowLayout.sectionInset.right - + flowLayout.minimumInteritemSpacing * (columns - 1) - return trunc((collectionView.bounds.width - spacing) / columns) - } } // MARK: - UICollectionViewDataSource @@ -111,7 +85,10 @@ extension MemoryDataSourceAndDelegate: UICollectionViewDataSource { ) -> UICollectionViewCell { // Dequeue only — actual configuration happens in `willDisplay` so that // cells are re-configured each time they enter the visible area. - collectionView.dequeueReusableCell(withReuseIdentifier: CardCVCell.cellIdentifier, for: indexPath) + collectionView.dequeueReusableCell( + withReuseIdentifier: CardCVCell.cellIdentifier, + for: indexPath + ) } } @@ -124,8 +101,8 @@ extension MemoryDataSourceAndDelegate: UICollectionViewDelegate { ) { let index = flatIndex(for: indexPath) // Gate on canSelectCard so matched cards cannot be tapped a second time. - guard canSelectCard?(index) == true else { return } - onCardTapped?(index) + guard canSelectCard(index) else { return } + onCardTapped(index) } func collectionView( @@ -137,7 +114,7 @@ extension MemoryDataSourceAndDelegate: UICollectionViewDelegate { // it fires every time a cell becomes visible, ensuring Kingfisher image loading // is triggered even after cells are recycled via the reuse pool. guard let cell = cell as? CardCVCell else { return } - configureCell?(cell, flatIndex(for: indexPath)) + configureCell(cell, flatIndex(for: indexPath)) } } @@ -160,6 +137,7 @@ extension MemoryDataSourceAndDelegate: UICollectionViewDelegateFlowLayout { sizeForItemAt _: IndexPath ) -> CGSize { guard let flowLayout = collectionViewLayout as? UICollectionViewFlowLayout else { return .zero } - return calculateCardSize(flowLayout, collectionView: collectionView) + let side = gridLayout.squareSide(in: collectionView.bounds.size, flowLayout: flowLayout) + return CGSize(width: side, height: side) } } diff --git a/SwiftIntro/Features/GameOver/GameOverVC.swift b/SwiftIntro/Features/GameOver/GameOverVC.swift index d31d7b2..4c79de7 100644 --- a/SwiftIntro/Features/GameOver/GameOverVC.swift +++ b/SwiftIntro/Features/GameOver/GameOverVC.swift @@ -29,10 +29,7 @@ protocol GameOverNavigatorProtocol: AnyObject { /// Thin by design: renders the outcome into `GameOverView` and delegates all /// navigation to `navigator`. No navigation logic lives here. final class GameOverVC: UIViewController { - /// The configuration from the completed game, passed to `navigator` on restart. - private let config: GameConfiguration - - /// The result of the completed game — click count, level, and the card deck. + /// The result of the completed game — config, click count, and the card deck. private let outcome: GameOutcome /// The root view; installed via `loadView()`. @@ -41,11 +38,7 @@ final class GameOverVC: UIViewController { /// Wired by the presenting controller (e.g. `RootVC`) before the push. weak var navigator: GameOverNavigatorProtocol? - init( - config: GameConfiguration, - outcome: GameOutcome - ) { - self.config = config + init(outcome: GameOutcome) { self.outcome = outcome super.init(nibName: nil, bundle: nil) } @@ -68,7 +61,7 @@ final class GameOverVC: UIViewController { gameOverView.onRestart = { [weak self] in guard let self else { return } logGame.info("Player chose Restart — starting new game with same images") - navigator?.restartGame(PreparedGame(config: config, cards: outcome.cards)) + navigator?.restartGame(PreparedGame(config: outcome.config, cards: outcome.cards)) } gameOverView.onQuit = { [weak self] in guard let self else { return } diff --git a/SwiftIntro/Features/GameOver/Model/GameOutcome.swift b/SwiftIntro/Features/GameOver/Model/GameOutcome.swift index 72c48e8..9c4f15a 100644 --- a/SwiftIntro/Features/GameOver/Model/GameOutcome.swift +++ b/SwiftIntro/Features/GameOver/Model/GameOutcome.swift @@ -10,14 +10,20 @@ import Foundation /// The result of a completed game session, passed to the game-over screen. struct GameOutcome { - /// The difficulty level that was played. - let level: Level + /// The configuration that produced this session — threaded through to the restart flow + /// so the next game starts with the same difficulty and search query, without reconstruction. + let config: GameConfiguration /// Total number of card taps the player made during the session. let clickCount: Int /// The deck used during the session, available so the player can restart with the same images. var cards: CardDuplicates + + /// The difficulty level that was played — derived from `config`. + var level: Level { + config.level + } } // MARK: CustomStringConvertible diff --git a/SwiftIntro/Features/GameOver/View/GameOverView.swift b/SwiftIntro/Features/GameOver/View/GameOverView.swift index 64628ac..960b2b0 100644 --- a/SwiftIntro/Features/GameOver/View/GameOverView.swift +++ b/SwiftIntro/Features/GameOver/View/GameOverView.swift @@ -78,11 +78,16 @@ private extension GameOverView { } } + /// Diameter applied to the circular restart and quit buttons. + static var circularButtonDiameter: CGFloat { + 80 + } + /// Wires tap targets for the restart and quit buttons and fixes their circular dimensions. func configureButtons() { restartButton.addTarget(self, action: #selector(restartTapped), for: .touchUpInside) quitButton.addTarget(self, action: #selector(quitTapped), for: .touchUpInside) - constrainButtonSizes(to: 80) + constrainButtonSizes(to: Self.circularButtonDiameter) } /// Fixes both buttons to an explicit `size × size` square so they stay perfectly circular. diff --git a/SwiftIntro/Features/GameSetup/View/GameSetupView.swift b/SwiftIntro/Features/GameSetup/View/GameSetupView.swift index a3513e3..a0ca2bf 100644 --- a/SwiftIntro/Features/GameSetup/View/GameSetupView.swift +++ b/SwiftIntro/Features/GameSetup/View/GameSetupView.swift @@ -9,9 +9,9 @@ import UIKit /// The setup game screen view — collects game configuration from the player before starting. /// -/// Manages its own `GameConfiguration` state internally and calls `onStartGame` -/// when the player taps the start button, passing the fully-built config out to -/// `GameSetupVC` for navigation. +/// The view holds no `GameConfiguration` state of its own: when the player taps +/// the start button, the current control values are read and a fresh +/// `GameConfiguration` is built and delivered via `onStartGame`. final class GameSetupView: UIView { /// Displays the app title at the top of the game setup screen. private lazy var titleLabel = UILabel() @@ -31,10 +31,6 @@ final class GameSetupView: UIView { /// Tapped to begin data loading and start the game. private lazy var startGameButton = UIButton(type: .system) - /// The configuration built from the current control values. - /// Updated incrementally as the player changes the level segment or text field. - private var config = GameConfiguration() - /// Called when the player taps "Start Game". Receives the finalized `GameConfiguration`. var onStartGame: ((GameConfiguration) -> Void)? @@ -111,30 +107,23 @@ private extension GameSetupView { } } - /// Attaches `@objc` action handlers to the segmented control and start button. + /// Attaches the `@objc` action handler to the start button. func wireTargets() { - levelSegmentedControl.addTarget(self, action: #selector(changedLevel(_:)), for: .valueChanged) startGameButton.addTarget(self, action: #selector(startGameTapped), for: .touchUpInside) } /// Seeds the controls with the default `GameConfiguration` values on first display. func populateViews() { - wikimediaQueryTextField.text = config.searchQuery - levelSegmentedControl.selectedSegmentIndex = config.level.segmentedControlIndex - } - - /// Updates `config.level` when the player changes the segmented control. - @objc func changedLevel(_ sender: UISegmentedControl) { - config.level = Level(segmentedControlIndex: sender.selectedSegmentIndex) - // swiftformat:disable:next redundantSelf - logGame.debug("Changed difficulty level to \(self.config.level)") + let defaults = GameConfiguration() + wikimediaQueryTextField.text = defaults.searchQuery + levelSegmentedControl.selectedSegmentIndex = defaults.level.segmentedControlIndex } - /// Reads the text field, updates `config.searchQuery` if non-empty, then fires `onStartGame`. + /// Reads the current control values and delivers a fresh `GameConfiguration` to `onStartGame`. @objc func startGameTapped() { - if let query = wikimediaQueryTextField.text, !query.isEmpty { - config.searchQuery = query - } - onStartGame?(config) + let level = Level(segmentedControlIndex: levelSegmentedControl.selectedSegmentIndex) + let defaults = GameConfiguration() + let query = wikimediaQueryTextField.text.flatMap { $0.isEmpty ? nil : $0 } ?? defaults.searchQuery + onStartGame?(GameConfiguration(level: level, searchQuery: query)) } } diff --git a/SwiftIntro/Features/Loading/LoadingVC.swift b/SwiftIntro/Features/Loading/LoadingVC.swift index ec2280e..36f9c09 100644 --- a/SwiftIntro/Features/Loading/LoadingVC.swift +++ b/SwiftIntro/Features/Loading/LoadingVC.swift @@ -6,7 +6,6 @@ // Copyright © 2016-2026 SwiftIntro. All rights reserved. // -import Diffuser import UIKit // MARK: - LoadingNavigatorProtocol @@ -20,11 +19,11 @@ protocol LoadingNavigatorProtocol: AnyObject { /// Installs `LoadingView`, wires the retry tap, and owns `LoadingViewModel`. /// -/// The diffuser is created here and injected into the view model at init time, -/// so the view model never holds an optional diffuser. +/// The VC is a thin wiring layer: it forwards retry taps to the view model and +/// renders phase updates back onto the view. All state lives in the view model. final class LoadingVC: UIViewController { /// Content view - private let loadingView: LoadingView + private let loadingView = LoadingView() /// ViewModel with logic private let viewModel: LoadingViewModel @@ -33,14 +32,7 @@ final class LoadingVC: UIViewController { weak var navigator: LoadingNavigatorProtocol? init(config: GameConfiguration) { - let view = LoadingView() - loadingView = view - viewModel = LoadingViewModel( - config: config - ) { [weak view] phase in - view?.render(phase) - } - view.onRetry = { [weak viewModel] in viewModel?.retry() } + viewModel = LoadingViewModel(config: config) super.init(nibName: nil, bundle: nil) } @@ -60,10 +52,15 @@ extension LoadingVC { override func viewDidLoad() { super.viewDidLoad() logNet.debug("LoadingVC loaded — starting data fetch") - viewModel.onNavigateToGame = { [weak self] game in - self?.navigator?.navigateToGame(game) - } - viewModel.start() + loadingView.onRetry = { [weak viewModel] in viewModel?.retry() } + viewModel.start( + onPhaseChange: { [weak self] phase in + self?.loadingView.render(phase) + }, + onNavigateToGame: { [weak self] game in + self?.navigator?.navigateToGame(game) + } + ) } override func viewDidDisappear(_ animated: Bool) { diff --git a/SwiftIntro/Features/Loading/LoadingViewModel.swift b/SwiftIntro/Features/Loading/LoadingViewModel.swift index 5ff47f8..42b6ed6 100644 --- a/SwiftIntro/Features/Loading/LoadingViewModel.swift +++ b/SwiftIntro/Features/Loading/LoadingViewModel.swift @@ -5,16 +5,19 @@ // Copyright © 2016-2026 SwiftIntro. All rights reserved. // -import Diffuser import Factory import UIKit /// Drives the loading screen — fetches data, pre-warms the image cache, and /// navigates to the game when ready. /// -/// `LoadingVC` creates a `Diffuser` and injects it at init time, so state -/// changes flow directly to `LoadingView` with no optionality or separate `start` wiring. +/// The view model is the single source of truth for the loading phase. `LoadingVC` +/// only renders snapshots delivered through `onPhaseChange` and navigates via +/// `onNavigateToGame` — no state lives in the view layer. final class LoadingViewModel { + typealias OnPhaseChange = (Phase) -> Void + typealias OnNavigateToGame = (PreparedGame) -> Void + // MARK: - Dependencies @Injected(\.wikimediaClient) private var wikimediaClient: WikimediaClientProtocol @@ -23,31 +26,28 @@ final class LoadingViewModel { // MARK: - State private let config: GameConfiguration - private let onPhaseChange: (Phase) -> Void - /// Current visual phase. Every assignment is automatically pushed to the view - /// via the diffuser — no optional unwrap, no manual `run` call at the call site. - private var phase: Phase = .initial { + /// Current visual phase. Every assignment is pushed to the view via `onPhaseChange`. + private var phase: Phase = .loading { didSet { // swiftformat:disable:next redundantSelf logApp.debug("phase: \(self.phase)") - onPhaseChange(phase) + onPhaseChange?(phase) } } - // MARK: - Navigation + // MARK: - Callbacks + + /// Fires whenever the phase changes — used by `LoadingView.render(_:)`. + var onPhaseChange: OnPhaseChange? - /// Called on the main thread when images are cached and the game is ready to start. - var onNavigateToGame: ((PreparedGame) -> Void)? + /// Fires once images are cached and the game is ready to start. + var onNavigateToGame: OnNavigateToGame? // MARK: - Init - init( - config: GameConfiguration, - onPhaseChange: @escaping (Phase) -> Void - ) { + init(config: GameConfiguration) { self.config = config - self.onPhaseChange = onPhaseChange } } @@ -56,7 +56,6 @@ final class LoadingViewModel { extension LoadingViewModel { /// The two visual states the loading screen can be in. enum Phase { - case initial /// Spinner shown — either fetching data or pre-warming the image cache. case loading /// Something went wrong — show the error message and retry button. @@ -67,7 +66,6 @@ extension LoadingViewModel { extension LoadingViewModel.Phase: CustomStringConvertible { var description: String { switch self { - case .initial: "Initial" case .loading: "Loading" case let .failed(error): "Failed: \(error)" } @@ -77,9 +75,17 @@ extension LoadingViewModel.Phase: CustomStringConvertible { // MARK: - Lifecycle extension LoadingViewModel { - /// Renders the initial state and kicks off the data fetch. - func start() { - // Logger interpolation is @autoclosure → closure context; compiler needs self. + /// Wires the callbacks, pushes the initial phase out, and kicks off the data fetch. + /// + /// The initial `.loading` render flows through `fetchData()` → `phase = .loading` → + /// `didSet`, so there's no explicit `onPhaseChange(phase)` call here to avoid + /// double-firing the callback for the same phase. + func start( + onPhaseChange: @escaping OnPhaseChange, + onNavigateToGame: @escaping OnNavigateToGame + ) { + self.onPhaseChange = onPhaseChange + self.onNavigateToGame = onNavigateToGame // swiftformat:disable:next redundantSelf logNet.info("LoadingViewModel starting — config: \(self.config)") fetchData() @@ -87,16 +93,19 @@ extension LoadingViewModel { /// Clears callbacks. Call from `viewDidDisappear`. func stop() { - logNet.debug("LoadingViewModel stopping — clearing navigation callback") + logNet.debug("LoadingViewModel stopping — clearing callbacks") + onPhaseChange = nil onNavigateToGame = nil } // MARK: - User actions /// Called when the player taps "Retry" after a failure. + /// + /// `fetchData()` sets `phase = .loading` internally, which drives the `didSet` + /// that notifies the view, so no explicit phase assignment is needed here. func retry() { logNet.info("Player tapped Retry — re-fetching images") - phase = .loading fetchData() } } @@ -128,9 +137,13 @@ extension LoadingViewModel { let urls = singles.cards.map(\.imageUrl) logNet.debug("Prefetching \(urls.count) image URL(s) into cache") imageCache.prefetchImages(urls) { [weak self] in - logNet.info("All images in memory cache — navigating to game") guard let self else { return } - onNavigateToGame?(PreparedGame(config: config, cards: cards)) + guard let onNavigateToGame else { + logNav.warning("Image prefetch completed but onNavigateToGame is nil — navigation skipped") + return + } + logNet.info("All images in memory cache — navigating to game") + onNavigateToGame(PreparedGame(config: config, cards: cards)) } } } diff --git a/SwiftIntro/Features/Loading/View/LoadingView.swift b/SwiftIntro/Features/Loading/View/LoadingView.swift index 9bb6444..a6a4922 100644 --- a/SwiftIntro/Features/Loading/View/LoadingView.swift +++ b/SwiftIntro/Features/Loading/View/LoadingView.swift @@ -48,8 +48,6 @@ extension LoadingView { /// Updates the view to match `phase`. func render(_ phase: LoadingViewModel.Phase) { switch phase { - case .initial: - logApp.trace("LoadingView -> phase: initial => NOOP") case .loading: setLoadingVisible(true) case .failed: diff --git a/SwiftIntro/Views/CellProtocol.swift b/SwiftIntro/Views/CellProtocol.swift index 29dc6dc..de75258 100644 --- a/SwiftIntro/Views/CellProtocol.swift +++ b/SwiftIntro/Views/CellProtocol.swift @@ -6,14 +6,18 @@ // Copyright © 2016-2026 SwiftIntro. All rights reserved. // -import UIKit - /// A collection view cell that can supply its own reuse identifier. /// -/// Conforming types (e.g. `CardCVCell`) derive `cellIdentifier` from `NSObject.className`, -/// keeping the identifier and the `register(_:forCellWithReuseIdentifier:)` call in sync -/// automatically. +/// A default implementation derives the identifier from the type name via +/// `String(describing:)`, so conforming cells do not have to declare one +/// explicitly. The identifier has no module prefix and no force-unwraps. protocol CellProtocol { /// The string used to register and dequeue this cell type. static var cellIdentifier: String { get } } + +extension CellProtocol { + static var cellIdentifier: String { + String(describing: Self.self) + } +} diff --git a/SwiftIntro/Views/SingleCellTypeCollectionView.swift b/SwiftIntro/Views/SingleCellTypeCollectionView.swift new file mode 100644 index 0000000..80b3442 --- /dev/null +++ b/SwiftIntro/Views/SingleCellTypeCollectionView.swift @@ -0,0 +1,70 @@ +// +// SingleCellTypeCollectionView.swift +// SwiftIntro +// +// Created by Alexander Cyon on 2026-04-23. +// Copyright © 2026 SwiftIntro. All rights reserved. +// + +import UIKit + +final class SingleCellTypeCollectionView: UICollectionView { + init( + dataSource: (any UICollectionViewDataSource)?, + delegate: (any UICollectionViewDelegate)? + ) { + let layout = UICollectionViewFlowLayout() + // Uniform spacing between rows and between columns. + layout.minimumLineSpacing = 8 + layout.minimumInteritemSpacing = 8 + super.init(frame: .zero, collectionViewLayout: layout) + + backgroundColor = .black + translatesAutoresizingMaskIntoConstraints = false + register(Cell.self, forCellWithReuseIdentifier: Self.reuseIdentifier) + self.delegate = delegate + self.dataSource = dataSource + } + + @available(*, unavailable, message: "Use init(dataSource:delegate:) instead.") + required init?(coder _: NSCoder) { + fatalError("init(coder:) is unavailable — use init(dataSource:delegate:) instead.") + } +} + +extension SingleCellTypeCollectionView { + func cellForItemAt(_ indexPath: IndexPath) -> Cell? { + guard let cell = super.cellForItem(at: indexPath) as? Cell else { + return nil + } + return cell + } + + func dequeueReusableCell(at indexPath: IndexPath) -> Cell { + let dequeued = dequeueReusableCell(withReuseIdentifier: Self.reuseIdentifier, for: indexPath) + guard let cell = dequeued as? Cell else { + fatalError( + "Programmer error — wrong cell type for reuseIdentifier '\(Self.reuseIdentifier)'. " + + "Expected: \(Cell.self), actual: \(type(of: dequeued))." + ) + } + return cell + } +} + +extension SingleCellTypeCollectionView { + static var reuseIdentifier: String { + Cell.cellIdentifier + } + + /// Creates and registers `Cell.self` as single cell kind. + /// + /// Inside this generic extension, the unqualified `SingleCellTypeCollectionView` + /// is implicitly specialised as `SingleCellTypeCollectionView`. + static func make() -> SingleCellTypeCollectionView { + SingleCellTypeCollectionView( + dataSource: nil, + delegate: nil + ) + } +} diff --git a/SwiftIntroTests/.swiftlint.yml b/SwiftIntroTests/.swiftlint.yml index bfedf76..943da5f 100644 --- a/SwiftIntroTests/.swiftlint.yml +++ b/SwiftIntroTests/.swiftlint.yml @@ -11,4 +11,12 @@ function_body_length: # several lines of Arrange / Act / Assert, so the 400-line default is too tight. file_length: warning: 500 - error: 500 \ No newline at end of file + error: 500 + +# Tests routinely use single-letter names for short-lived locals (`vc`, `vm`, +# `cv`, `ds`, `a`, `b`) where a longer name would just add noise. Lower the +# floor to 1 char in test code only — production code keeps the stricter rule. +identifier_name: + min_length: + warning: 1 + error: 1 \ No newline at end of file diff --git a/SwiftIntroTests/App/RootVCTests.swift b/SwiftIntroTests/App/RootVCTests.swift index 353001a..1f81c33 100644 --- a/SwiftIntroTests/App/RootVCTests.swift +++ b/SwiftIntroTests/App/RootVCTests.swift @@ -126,7 +126,11 @@ final class RootVCTests: XCTestCase { func test_navigateToGameOver_pushesGameOverVC() { // Arrange let root = RootVC() - let outcome = GameOutcome(level: .easy, clickCount: 4, cards: makeCards(count: 6)) + let outcome = GameOutcome( + config: GameConfiguration(level: .easy), + clickCount: 4, + cards: makeCards(count: 6) + ) // Act root.navigateToGameOver(outcome: outcome) @@ -139,7 +143,11 @@ final class RootVCTests: XCTestCase { func test_navigateToGameOver_setsNavigatorOnGameOverVC() { // Arrange let root = RootVC() - let outcome = GameOutcome(level: .easy, clickCount: 4, cards: makeCards(count: 6)) + let outcome = GameOutcome( + config: GameConfiguration(level: .easy), + clickCount: 4, + cards: makeCards(count: 6) + ) // Act root.navigateToGameOver(outcome: outcome) diff --git a/SwiftIntroTests/Dependencies/ImmediateClock.swift b/SwiftIntroTests/Dependencies/ImmediateClock.swift new file mode 100644 index 0000000..024a34e --- /dev/null +++ b/SwiftIntroTests/Dependencies/ImmediateClock.swift @@ -0,0 +1,25 @@ +// +// ImmediateClock.swift +// SwiftIntroTests +// +// Copyright © 2016-2026 SwiftIntro. All rights reserved. +// + +import Foundation +@testable import SwiftIntro + +/// Test clock: ignores the requested delay and fires on the next main-queue cycle. +/// +/// Register it in `setUp` via `Container.shared.clock.register { ImmediateClock() }` +/// so timer-dependent code under test runs in milliseconds rather than seconds. +final class ImmediateClock: Clock { + @discardableResult + func schedule( + after _: TimeInterval, + execute block: @escaping () -> Void + ) -> DispatchWorkItem { + let item = DispatchWorkItem(block: block) + DispatchQueue.main.async(execute: item) + return item + } +} diff --git a/SwiftIntroTests/Features/Game/Logic/GameModelTests.swift b/SwiftIntroTests/Features/Game/GameModelTests.swift similarity index 100% rename from SwiftIntroTests/Features/Game/Logic/GameModelTests.swift rename to SwiftIntroTests/Features/Game/GameModelTests.swift diff --git a/SwiftIntroTests/Features/Game/GameVCTests.swift b/SwiftIntroTests/Features/Game/GameVCTests.swift index d559464..d9c1aa1 100644 --- a/SwiftIntroTests/Features/Game/GameVCTests.swift +++ b/SwiftIntroTests/Features/Game/GameVCTests.swift @@ -10,17 +10,18 @@ // - Assert: verify a single observable outcome (1 line) // // Notes on approach: -// - `_ = vc.view` triggers loadView + viewDidLoad (starts the Mobius loop). -// - `vc.viewDidDisappear(false)` stops the loop after each such test. -// - `dataSourceAndDelegate` is retrieved via the collectionView's dataSource -// property, which is set to it during `setupCollectionView`. -// - `connect` is called a second time in some tests to capture events with a -// test-controlled consumer; this overwrites the loop's internal wiring of -// `onCardTapped`, which is safe in a test context. +// - `_ = vc.view` triggers `loadView()` and installs `GameView`, wiring the data +// source/delegate closures through to the view model. +// - The initial `onModelChanged` callback (which renders the score label) fires +// later from `viewWillAppear` via `viewModel.start(...)`, so tests that assert +// on the rendered score must invoke `viewWillAppear(_:)` explicitly. +// - `vc.viewDidDisappear(false)` calls `viewModel.stop()`, cancelling any pending +// flip-back timers. +// - `dataSourceAndDelegate` is retrieved via the collection view's dataSource +// property, which is set when `GameView` is initialised. // import Factory -import MobiusCore @testable import SwiftIntro import UIKit import XCTest @@ -59,29 +60,51 @@ final class GameVCTests: XCTestCase { )) } - private func makeModel( - level: Level = .easy, - matches: Int = 0 - ) -> GameModel { - let cards = (0 ..< level.cardCount).map { - CardModel(card: Card(imageUrl: URL(string: "https://a.test/\($0).jpg")!)) - } - var model = GameModel(cards: cards, level: level) - model.matches = matches - return model + /// Casts `vc.view` to `GameView`. Throws if the type is wrong so the test + /// terminates immediately rather than continuing with a stand-in instance. + private func gameView( + of vc: GameVC, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> GameView { + try XCTUnwrap( + vc.view as? GameView, + "Expected vc.view to be GameView, got \(type(of: vc.view))", + file: file, + line: line + ) } - /// Casts `vc.view` to `GameView`. Crashes the test if the type is wrong. - private func gameView(of vc: GameVC) -> GameView { - // swiftlint:disable:next force_cast - vc.view as! GameView + /// Locates the card grid's `UICollectionView` by traversing `GameView`'s + /// subviews. `GameView.collectionView` is private by design, so tests reach + /// it through the view hierarchy rather than adding a test-only accessor. + private func collectionView( + of vc: GameVC, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> UICollectionView { + try XCTUnwrap( + gameView(of: vc, file: file, line: line) + .subviews.compactMap { $0 as? UICollectionView }.first, + "No UICollectionView found in GameView subviews", + file: file, + line: line + ) } /// Retrieves the `MemoryDataSourceAndDelegate` from the collection view's `dataSource` /// property. Only valid after `viewDidLoad` has run. - private func dataSourceAndDelegate(of vc: GameVC) -> MemoryDataSourceAndDelegate { - // swiftlint:disable:next force_cast - gameView(of: vc).collectionView.dataSource as! MemoryDataSourceAndDelegate + private func dataSourceAndDelegate( + of vc: GameVC, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> MemoryDataSourceAndDelegate { + try XCTUnwrap( + try collectionView(of: vc, file: file, line: line).dataSource as? MemoryDataSourceAndDelegate, + "Expected dataSource to be MemoryDataSourceAndDelegate", + file: file, + line: line + ) } // MARK: - init @@ -112,7 +135,7 @@ final class GameVCTests: XCTestCase { // MARK: - viewDidLoad - func test_viewDidLoad_setsCollectionViewDataSource() { + func test_viewDidLoad_setsCollectionViewDataSource() throws { // Arrange let vc = makeVC() @@ -120,11 +143,11 @@ final class GameVCTests: XCTestCase { _ = vc.view // Assert - XCTAssertNotNil(gameView(of: vc).collectionView.dataSource) + XCTAssertNotNil(try collectionView(of: vc).dataSource) vc.viewDidDisappear(false) } - func test_viewDidLoad_setsCollectionViewDelegate() { + func test_viewDidLoad_setsCollectionViewDelegate() throws { // Arrange let vc = makeVC() @@ -132,11 +155,11 @@ final class GameVCTests: XCTestCase { _ = vc.view // Assert - XCTAssertNotNil(gameView(of: vc).collectionView.delegate) + XCTAssertNotNil(try collectionView(of: vc).delegate) vc.viewDidDisappear(false) } - func test_viewDidLoad_dataSourceIsMemoryDataSourceAndDelegate() { + func test_viewDidLoad_dataSourceIsMemoryDataSourceAndDelegate() throws { // Arrange let vc = makeVC() @@ -144,184 +167,138 @@ final class GameVCTests: XCTestCase { _ = vc.view // Assert - XCTAssertTrue(gameView(of: vc).collectionView.dataSource is MemoryDataSourceAndDelegate) + XCTAssertTrue(try collectionView(of: vc).dataSource is MemoryDataSourceAndDelegate) vc.viewDidDisappear(false) } - func test_viewDidLoad_canSelectCardClosureIsWired() { + func test_viewDidLoad_canSelectCardClosure_returnsTrueForUnmatchedCard() throws { // Arrange let vc = makeVC() // Act _ = vc.view - // Assert — the closure is set so the data source can gate taps - XCTAssertNotNil(dataSourceAndDelegate(of: vc).canSelectCard) + // Assert — the closure is wired to the view model; a fresh deck has no matched cards + XCTAssertTrue(try dataSourceAndDelegate(of: vc).canSelectCard(0)) vc.viewDidDisappear(false) } - func test_viewDidLoad_configureCellClosureIsWired() { + func test_viewDidLoad_configureCellClosure_doesNotCrash() throws { // Arrange let vc = makeVC() - - // Act _ = vc.view + let cell = CardCVCell(frame: CGRect(origin: .zero, size: CGSize(width: 80, height: 100))) + let ds = try dataSourceAndDelegate(of: vc) - // Assert — the closure is set so cells are configured on willDisplay - XCTAssertNotNil(dataSourceAndDelegate(of: vc).configureCell) + // Act + Assert — the closure is wired to the view model + XCTAssertNoThrow(ds.configureCell(cell, 0)) vc.viewDidDisappear(false) } - // MARK: - viewDidDisappear - - func test_viewDidDisappear_doesNotCrash() { + func test_viewDidLoad_onCardTappedClosure_doesNotCrash() throws { // Arrange let vc = makeVC() - _ = vc.view - - // Act + Assert - XCTAssertNoThrow(vc.viewDidDisappear(false)) - } - - func test_viewDidDisappear_calledTwice_doesNotCrash() { - // Arrange — guard against double-stop if a VC disappears more than once - let vc = makeVC() - _ = vc.view - // Act + Assert - vc.viewDidDisappear(false) - XCTAssertNoThrow(vc.viewDidDisappear(false)) - } - - // MARK: - connect - - func test_connect_onCardTapped_dispatchesCardTappedIndex() { - // Arrange - let vc = makeVC() + // Act + Assert — the closure is wired to the view model _ = vc.view - var receivedIndex: Int? - let conn = vc.connect { event in - if case let .cardTapped(index) = event { receivedIndex = index } - } - - // Act — fire the wired closure directly, bypassing UICollectionView - dataSourceAndDelegate(of: vc).onCardTapped?(7) - - // Assert - XCTAssertEqual(receivedIndex, 7) - conn.dispose() + let ds = try dataSourceAndDelegate(of: vc) + XCTAssertNoThrow(ds.onCardTapped(0)) vc.viewDidDisappear(false) } - func test_connect_onCardTapped_dispatchesCorrectIndexForEachCall() { - // Arrange + func test_viewWillAppear_setsScoreLabelText() throws { + // Arrange — start() (invoked from viewWillAppear) fires the initial + // onModelChanged so the score renders. let vc = makeVC() _ = vc.view - var indices: [Int] = [] - let conn = vc.connect { event in - if case let .cardTapped(index) = event { indices.append(index) } - } // Act - dataSourceAndDelegate(of: vc).onCardTapped?(0) - dataSourceAndDelegate(of: vc).onCardTapped?(3) + vc.viewWillAppear(false) // Assert - XCTAssertEqual(indices, [0, 3]) - conn.dispose() + XCTAssertNotNil(try gameView(of: vc).headerView.scoreLabel.text) vc.viewDidDisappear(false) } - func test_connect_acceptClosure_setsScoreLabelText() { + // MARK: - viewDidDisappear + + func test_viewDidDisappear_doesNotCrash() { // Arrange let vc = makeVC() _ = vc.view - let conn = vc.connect { _ in } - - // Act - conn.accept(makeModel()) - // Assert — render() always sets the score label to a non-nil string - XCTAssertNotNil(gameView(of: vc).headerView.scoreLabel.text) - conn.dispose() - vc.viewDidDisappear(false) + // Act + Assert + XCTAssertNoThrow(vc.viewDidDisappear(false)) } - func test_connect_acceptClosure_reflectsMatchCountInScoreLabel() { - // Arrange + func test_viewDidDisappear_calledTwice_doesNotCrash() { + // Arrange — guard against double-stop if a VC disappears more than once let vc = makeVC() _ = vc.view - let conn = vc.connect { _ in } - // Act - conn.accept(makeModel(matches: 2)) - - // Assert — the score label text contains the current match count - XCTAssertTrue(gameView(of: vc).headerView.scoreLabel.text?.contains("2") ?? false) - conn.dispose() + // Act + Assert vc.viewDidDisappear(false) + XCTAssertNoThrow(vc.viewDidDisappear(false)) } - func test_connect_disposeClosure_nilsOnCardTapped() { + // MARK: - data source closures + + func test_canSelectCard_closure_returnsTrueForUnmatchedCard() throws { // Arrange let vc = makeVC() _ = vc.view - let conn = vc.connect { _ in } - XCTAssertNotNil(dataSourceAndDelegate(of: vc).onCardTapped) // Act - conn.dispose() + let result = try dataSourceAndDelegate(of: vc).canSelectCard(0) - // Assert — disposeClosure clears the closure so taps are silenced after disconnect - XCTAssertNil(dataSourceAndDelegate(of: vc).onCardTapped) + // Assert — fresh deck has no matched cards + XCTAssertTrue(result) vc.viewDidDisappear(false) } - // MARK: - wireDataSourceClosures - - func test_canSelectCard_closure_returnsTrueForUnmatchedCard() { + func test_configureCell_closure_doesNotCrash() throws { // Arrange let vc = makeVC() _ = vc.view + let cell = CardCVCell(frame: CGRect(origin: .zero, size: CGSize(width: 80, height: 100))) + let ds = try dataSourceAndDelegate(of: vc) - // Act — invoke the canSelectCard closure body wired in wireDataSourceClosures - let result = dataSourceAndDelegate(of: vc).canSelectCard?(0) - - // Assert — fresh model has no matched cards - XCTAssertEqual(result, true) + // Act + Assert + XCTAssertNoThrow(ds.configureCell(cell, 0)) vc.viewDidDisappear(false) } - func test_configureCell_closure_doesNotCrash() { + func test_onCardTapped_closure_doesNotCrash() throws { // Arrange let vc = makeVC() _ = vc.view - let cell = CardCVCell(frame: CGRect(origin: .zero, size: CGSize(width: 80, height: 100))) + let ds = try dataSourceAndDelegate(of: vc) - // Act + Assert — invoke the configureCell closure body wired in wireDataSourceClosures - XCTAssertNoThrow(dataSourceAndDelegate(of: vc).configureCell?(cell, 0)) + // Act + Assert — invoking the tap should drive the view model without crashing + XCTAssertNoThrow(ds.onCardTapped(0)) vc.viewDidDisappear(false) } // MARK: - navigateToGameOver - func test_navigateToGameOver_callsNavigatorWithOutcome() { - // Arrange — 3 paired cards (easy = 3 pairs). Deck is shuffled, so locate - // matching index pairs by URL after construction. + func test_navigateToGameOver_callsNavigatorWithOutcome() throws { + // Arrange — easy = 3 pairs. Build a deck and locate matching index pairs by URL. let pairedCards = makePairedCards(pairCount: 3) let pairs = pairIndices(in: pairedCards) let vc = GameVC(PreparedGame(config: GameConfiguration(level: .easy), cards: pairedCards)) let spy = SpyGameNavigator() vc.navigator = spy _ = vc.view + // viewWillAppear wires the navigator callback via viewModel.start(...) + vc.viewWillAppear(false) let exp = expectation(description: "navigateToGameOver called") spy.onNavigateToGameOver = { exp.fulfill() } // Act — tap each pair; the last match triggers the navigator via ImmediateClock - let ds = dataSourceAndDelegate(of: vc) + let ds = try dataSourceAndDelegate(of: vc) for (first, second) in pairs { - ds.onCardTapped?(first) - ds.onCardTapped?(second) + ds.onCardTapped(first) + ds.onCardTapped(second) } // Assert — ImmediateClock fires on the next main-queue cycle, well within 1 s diff --git a/SwiftIntroTests/Features/Game/GameViewModelTests.swift b/SwiftIntroTests/Features/Game/GameViewModelTests.swift new file mode 100644 index 0000000..59c271e --- /dev/null +++ b/SwiftIntroTests/Features/Game/GameViewModelTests.swift @@ -0,0 +1,383 @@ +// +// GameViewModelTests.swift +// SwiftIntroTests +// +// Copyright © 2016-2026 SwiftIntro. All rights reserved. +// +// All tests follow the Arrange-Act-Assert (AAA) pattern: +// - Arrange: build a view model with a known prepared game (1–5 lines) +// - Act: call the method under test (1 line) +// - Assert: verify a single observable outcome (1 line) +// + +import Factory +@testable import SwiftIntro +import UIKit +import XCTest + +final class GameViewModelTests: XCTestCase { + override func setUp() { + super.setUp() + Container.shared.clock.register { ImmediateClock() } + } + + override func tearDown() { + Container.shared.clock.reset() + super.tearDown() + } + + // MARK: - Helpers + + private func makeCard(index: Int) -> Card { + Card(imageUrl: URL(string: "https://a.test/\(index).jpg")!) + } + + /// Returns a `CardDuplicates` deck of `count` cards (must be even) where each + /// image appears exactly twice. Order is shuffled inside `CardDuplicates.init`. + private func makeShuffledDeck(count: Int) -> CardDuplicates { + let paired = (0 ..< count / 2).flatMap { i -> [Card] in + let card = makeCard(index: i) + return [card, card] + } + return CardDuplicates(reshuffling: paired) + } + + /// Returns a deck whose `memoryCards` are deterministically ordered as + /// `[A, A, B, B, C, C, ...]` so the tests can rely on specific indices being + /// matching pairs (0/1, 2/3, ...). + private func makeOrderedDeck(pairCount: Int) -> CardDuplicates { + let canonical = (0 ..< pairCount).flatMap { i -> [Card] in + let card = makeCard(index: i) + return [card, card] + } + return CardDuplicates(ordered: canonical) + } + + private func makeViewModel( + level: Level = .easy, + deck: CardDuplicates? = nil + ) -> GameViewModel { + let cards = deck ?? makeShuffledDeck(count: level.cardCount) + return GameViewModel(PreparedGame(config: GameConfiguration(level: level), cards: cards)) + } + + // MARK: - init + + func test_init_easy_levelMatches() { + // Act + let vm = makeViewModel(level: .easy) + + // Assert + XCTAssertEqual(vm.level, .easy) + } + + func test_init_normal_levelMatches() { + // Act + let vm = makeViewModel(level: .normal) + + // Assert + XCTAssertEqual(vm.level, .normal) + } + + func test_init_hard_levelMatches() { + // Act + let vm = makeViewModel(level: .hard) + + // Assert + XCTAssertEqual(vm.level, .hard) + } + + // MARK: - start + + func test_start_firesOnModelChangedWithInitialModel() { + // Arrange + let vm = makeViewModel() + var receivedModel: GameModel? + + // Act + vm.start( + onModelChanged: { receivedModel = $0 }, + onFlipCard: { _, _ in }, + onNavigateToGameOver: { _ in } + ) + + // Assert + XCTAssertEqual(receivedModel?.matches, 0) + } + + func test_start_initialModelHasZeroClickCount() { + // Arrange + let vm = makeViewModel() + var receivedModel: GameModel? + + // Act + vm.start( + onModelChanged: { receivedModel = $0 }, + onFlipCard: { _, _ in }, + onNavigateToGameOver: { _ in } + ) + + // Assert + XCTAssertEqual(receivedModel?.clickCount, 0) + } + + // MARK: - stop + + func test_stop_calledTwice_doesNotCrash() { + // Arrange + let vm = makeViewModel() + + // Act + vm.stop() + + // Assert + XCTAssertNoThrow(vm.stop()) + } + + func test_stop_cancelsPendingFlipBackTimer() { + // Arrange — non-matching pair schedules a flip-back. Then stop before it fires. + let deck = makeOrderedDeck(pairCount: 3) + let vm = makeViewModel(level: .easy, deck: deck) + var flipDownsAfterStop = 0 + vm.onFlipCard = { _, _ in } // absorb initial flip-up callbacks + vm.cardTapped(at: 0) + vm.cardTapped(at: 2) + vm.onFlipCard = { _, faceUp in if !faceUp { flipDownsAfterStop += 1 } } + + // Act + vm.stop() + let waiter = expectation(description: "main queue drain") + DispatchQueue.main.async { waiter.fulfill() } + waitForExpectations(timeout: 1) + + // Assert — without cancellation, two face-down callbacks would have fired + XCTAssertEqual(flipDownsAfterStop, 0) + } + + // MARK: - canSelectCard + + func test_canSelectCard_returnsTrueForFreshCard() { + // Arrange + let vm = makeViewModel() + + // Act + let canSelect = vm.canSelectCard(at: 0) + + // Assert + XCTAssertTrue(canSelect) + } + + func test_canSelectCard_returnsFalseAfterCardIsMatched() { + // Arrange — match indices 0 and 1 of an ordered (AABBCC) deck + let deck = makeOrderedDeck(pairCount: 3) + let vm = makeViewModel(level: .easy, deck: deck) + vm.cardTapped(at: 0) + vm.cardTapped(at: 1) + + // Act + let canSelect = vm.canSelectCard(at: 0) + + // Assert + XCTAssertFalse(canSelect) + } + + // MARK: - configureCell + + func test_configureCell_doesNotCrash() { + // Arrange + let vm = makeViewModel() + let cell = CardCVCell(frame: CGRect(origin: .zero, size: CGSize(width: 80, height: 100))) + + // Act + Assert + XCTAssertNoThrow(vm.configureCell(cell, at: 0)) + } + + // MARK: - cardTapped — invalid input + + func test_cardTapped_outOfBounds_doesNotFireOnFlipCard() { + // Arrange + let vm = makeViewModel() + var fired = false + vm.onFlipCard = { _, _ in fired = true } + + // Act + vm.cardTapped(at: 999) + + // Assert + XCTAssertFalse(fired) + } + + func test_cardTapped_outOfBounds_doesNotFireOnModelChanged() { + // Arrange + let vm = makeViewModel() + var fired = false + vm.onModelChanged = { _ in fired = true } + + // Act + vm.cardTapped(at: 999) + + // Assert + XCTAssertFalse(fired) + } + + func test_cardTapped_alreadyFlipped_doesNotFlipAgain() { + // Arrange — tap once to flip, then again on the same index + let vm = makeViewModel() + vm.cardTapped(at: 0) + var flipCalls = 0 + vm.onFlipCard = { _, _ in flipCalls += 1 } + + // Act + vm.cardTapped(at: 0) + + // Assert + XCTAssertEqual(flipCalls, 0) + } + + // MARK: - cardTapped — first card of a turn + + func test_cardTapped_firstCard_firesOnFlipCardFaceUp() { + // Arrange + let vm = makeViewModel() + var receivedFaceUp: Bool? + vm.onFlipCard = { _, faceUp in receivedFaceUp = faceUp } + + // Act + vm.cardTapped(at: 0) + + // Assert + XCTAssertEqual(receivedFaceUp, true) + } + + func test_cardTapped_firstCard_emitsModelWithIncrementedClickCount() { + // Arrange + let vm = makeViewModel() + var receivedModel: GameModel? + vm.onModelChanged = { receivedModel = $0 } + + // Act + vm.cardTapped(at: 0) + + // Assert + XCTAssertEqual(receivedModel?.clickCount, 1) + } + + // MARK: - cardTapped — intermediate match + + func test_cardTapped_intermediateMatch_emitsIncrementedMatchCount() { + // Arrange — pair 0 sits at deck indices (0, 1) in an AABBCC deck + let deck = makeOrderedDeck(pairCount: 3) + let vm = makeViewModel(level: .easy, deck: deck) + var lastModel: GameModel? + vm.onModelChanged = { lastModel = $0 } + vm.cardTapped(at: 0) + + // Act + vm.cardTapped(at: 1) + + // Assert + XCTAssertEqual(lastModel?.matches, 1) + } + + func test_cardTapped_intermediateMatch_doesNotTriggerNavigation() { + // Arrange + let deck = makeOrderedDeck(pairCount: 3) + let vm = makeViewModel(level: .easy, deck: deck) + var navigated = false + vm.onNavigateToGameOver = { _ in navigated = true } + vm.cardTapped(at: 0) + + // Act + vm.cardTapped(at: 1) + let waiter = expectation(description: "main queue drain") + DispatchQueue.main.async { waiter.fulfill() } + waitForExpectations(timeout: 1) + + // Assert — pairs remain after a single match in a 3-pair deck + XCTAssertFalse(navigated) + } + + // MARK: - cardTapped — non-matching pair triggers flip-back + + func test_cardTapped_nonMatch_schedulesTwoFlipDownsAfterDelay() { + // Arrange — index 0 (pair 0) and index 2 (pair 1) do not match + let deck = makeOrderedDeck(pairCount: 3) + let vm = makeViewModel(level: .easy, deck: deck) + var flipDownCalls = 0 + vm.onFlipCard = { _, faceUp in if !faceUp { flipDownCalls += 1 } } + vm.cardTapped(at: 0) + + // Act + vm.cardTapped(at: 2) + let waiter = expectation(description: "flip-back delay drained") + DispatchQueue.main.async { waiter.fulfill() } + waitForExpectations(timeout: 1) + + // Assert — both cards flip back face-down + XCTAssertEqual(flipDownCalls, 2) + } + + // MARK: - cardTapped — final match → game over + + func test_cardTapped_lastMatch_firesOnNavigateToGameOver() { + // Arrange — match all three pairs in an AABBCC easy deck + let vm = makeViewModel(level: .easy, deck: makeOrderedDeck(pairCount: 3)) + let exp = expectation(description: "navigateToGameOver fired") + vm.onNavigateToGameOver = { _ in exp.fulfill() } + + // Act + playMatchingPairs(in: vm, pairCount: 3) + + // Assert — ImmediateClock fires on the next main-queue cycle + waitForExpectations(timeout: 1) + } + + func test_cardTapped_lastMatch_navigateOutcomeCarriesCorrectClickCount() { + // Arrange + let vm = makeViewModel(level: .easy, deck: makeOrderedDeck(pairCount: 3)) + let exp = expectation(description: "navigateToGameOver fired") + var receivedOutcome: GameOutcome? + vm.onNavigateToGameOver = { + receivedOutcome = $0 + exp.fulfill() + } + + // Act + playMatchingPairs(in: vm, pairCount: 3) + + // Assert + waitForExpectations(timeout: 1) + XCTAssertEqual(receivedOutcome?.clickCount, 6) + } + + func test_cardTapped_lastMatch_navigateOutcomeCarriesLevel() { + // Arrange + let vm = makeViewModel(level: .easy, deck: makeOrderedDeck(pairCount: 3)) + let exp = expectation(description: "navigateToGameOver fired") + var receivedOutcome: GameOutcome? + vm.onNavigateToGameOver = { + receivedOutcome = $0 + exp.fulfill() + } + + // Act + playMatchingPairs(in: vm, pairCount: 3) + + // Assert + waitForExpectations(timeout: 1) + XCTAssertEqual(receivedOutcome?.level, .easy) + } + + // MARK: - Pairing helper + + /// Taps each `(2i, 2i+1)` pair in turn — assumes the deck is in canonical AABBCC order. + private func playMatchingPairs( + in viewModel: GameViewModel, + pairCount: Int + ) { + for pairIndex in 0 ..< pairCount { + viewModel.cardTapped(at: pairIndex * 2) + viewModel.cardTapped(at: pairIndex * 2 + 1) + } + } +} diff --git a/SwiftIntroTests/Features/Game/Logic/GameEffectHandlerTests.swift b/SwiftIntroTests/Features/Game/Logic/GameEffectHandlerTests.swift deleted file mode 100644 index f0952e1..0000000 --- a/SwiftIntroTests/Features/Game/Logic/GameEffectHandlerTests.swift +++ /dev/null @@ -1,247 +0,0 @@ -// -// GameEffectHandlerTests.swift -// SwiftIntroTests -// -// Copyright © 2016-2026 SwiftIntro. All rights reserved. -// -// All tests follow the Arrange-Act-Assert (AAA) pattern: -// - Arrange: build a handler with a known initial model (1–5 lines) -// - Act: call the method under test (1 line) -// - Assert: verify a single observable outcome (1 line) -// - -import Factory -@testable import SwiftIntro -import UIKit -import XCTest - -// MARK: - ImmediateClock - -/// Test clock: ignores the requested delay and fires on the next main-queue cycle. -/// -/// Defined as `internal` so every test file in this module can share it. -/// Register it in `setUp` via `Container.shared.clock.register { ImmediateClock() }`. -final class ImmediateClock: Clock { - @discardableResult - func schedule( - after _: TimeInterval, - execute block: @escaping () -> Void - ) -> DispatchWorkItem { - let item = DispatchWorkItem(block: block) - DispatchQueue.main.async(execute: item) - return item - } -} - -// MARK: - Tests - -final class GameEffectHandlerTests: XCTestCase { - override func setUp() { - super.setUp() - Container.shared.clock.register { ImmediateClock() } - } - - override func tearDown() { - Container.shared.clock.reset() - super.tearDown() - } - - // MARK: - Helpers - - private func makeCard( - url: URL = URL(string: "https://a.test/img.jpg")!, - isFlipped: Bool = false, - isMatched: Bool = false - ) -> CardModel { - var card = CardModel(card: Card(imageUrl: url)) - card.isFlipped = isFlipped - card.isMatched = isMatched - return card - } - - private func makeModel( - cards: [CardModel] = [], - level: Level = .easy - ) -> GameModel { - GameModel(cards: cards, level: level) - } - - private func makeHandler( - model: GameModel? = nil, - level: Level = .easy - ) -> GameEffectHandler { - let initialModel = model ?? makeModel(level: level) - return GameEffectHandler(initialModel: initialModel) - } - - // MARK: - canSelectCard - - func test_canSelectCard_returnsTrueForUnmatchedCard() { - // Arrange - let cards = [makeCard(isMatched: false)] - let handler = makeHandler(model: makeModel(cards: cards)) - - // Act - let result = handler.canSelectCard(at: 0) - - // Assert - XCTAssertTrue(result) - } - - func test_canSelectCard_returnsFalseForMatchedCard() { - // Arrange - let cards = [makeCard(isMatched: true)] - let handler = makeHandler(model: makeModel(cards: cards)) - - // Act - let result = handler.canSelectCard(at: 0) - - // Assert - XCTAssertFalse(result) - } - - // MARK: - update(with:) - - func test_update_canSelectCard_reflectsUpdatedModel() { - // Arrange — initially unmatched - let cards = [makeCard(isMatched: false)] - let handler = makeHandler(model: makeModel(cards: cards)) - - // Act — update with a model where the card is now matched - var matched = makeModel(cards: cards) - matched.cards[0].isMatched = true - handler.update(with: matched) - - // Assert - XCTAssertFalse(handler.canSelectCard(at: 0)) - } - - // MARK: - connect(_:) - - func test_connect_returnsNonNilConnection() { - // Arrange - let handler = makeHandler() - - // Act - let connection = handler.connect { _ in } - - // Assert - XCTAssertNotNil(connection) - } - - func test_connect_disposeDoesNotCrashWithNoPendingWorkItem() { - // Arrange - let handler = makeHandler() - let connection = handler.connect { _ in } - - // Act + Assert — dispose must not crash when no flip-back timer is pending - XCTAssertNoThrow(connection.dispose()) - } - - // MARK: - flipCard effect (via connect) - - func test_connect_flipCardEffect_doesNotCrashWithNoCollectionView() { - // Arrange — no collectionView assigned - let handler = makeHandler() - let connection = handler.connect { _ in } - let exp = expectation(description: "main queue drain") - - // Act - connection.accept(.flipCard(index: 0, faceUp: true)) - DispatchQueue.main.async { exp.fulfill() } - - // Assert — must not crash - waitForExpectations(timeout: 1) - XCTAssertNil(handler.collectionView) - } - - func test_connect_flipCardEffect_withCollectionView_doesNotCrash() { - // Arrange — non-nil collectionView exercises indexPath(for:) even though - // cellForItem(at:) returns nil (no window) and animateFlip is skipped. - let cv = UICollectionView( - frame: CGRect(x: 0, y: 0, width: 300, height: 400), - collectionViewLayout: UICollectionViewFlowLayout() - ) - let handler = makeHandler() - handler.collectionView = cv - let connection = handler.connect { _ in } - let exp = expectation(description: "main queue drain") - - // Act - connection.accept(.flipCard(index: 0, faceUp: true)) - DispatchQueue.main.async { exp.fulfill() } - - // Assert — must not crash; collectionView reference is still set - waitForExpectations(timeout: 5) - XCTAssertNotNil(handler.collectionView) - connection.dispose() - } - - // MARK: - scheduleFlipBack effect (via connect) - - func test_connect_scheduleFlipBackEffect_dispatchesFlipBackEvent() { - // Arrange - let handler = makeHandler() - let exp = expectation(description: "flipBackCards event dispatched") - var receivedEvent: GameEvent? - let connection = handler.connect { event in - receivedEvent = event - exp.fulfill() - } - - // Act - connection.accept(.scheduleFlipBack(index1: 0, index2: 1)) - - // Assert — ImmediateClock fires on the next main-queue cycle - waitForExpectations(timeout: 1) - if case let .flipBackCards(i1, i2) = receivedEvent { - XCTAssertEqual(i1, 0) - XCTAssertEqual(i2, 1) - } else { - XCTFail("Expected flipBackCards event") - } - connection.dispose() - } - - func test_connect_scheduleFlipBackEffect_canBeCancelledByDispose() { - // Arrange - let handler = makeHandler() - var didDispatch = false - let connection = handler.connect { _ in didDispatch = true } - - // Act — schedule, then immediately dispose to cancel before the async block fires - connection.accept(.scheduleFlipBack(index1: 0, index2: 1)) - connection.dispose() - - // Assert — drain one main-queue cycle; the cancelled item must not have fired - let waiter = expectation(description: "main queue drain") - DispatchQueue.main.async { waiter.fulfill() } - waitForExpectations(timeout: 1) - XCTAssertFalse(didDispatch) - } - - // MARK: - navigateToGameOver effect (via connect) - - func test_connect_navigateToGameOverEffect_callsOnNavigateToGameOver() { - // Arrange - let handler = makeHandler() - let exp = expectation(description: "navigate called") - var receivedOutcome: GameOutcome? - handler.onNavigateToGameOver = { outcome in - receivedOutcome = outcome - exp.fulfill() - } - let connection = handler.connect { _ in } - let card = Card(imageUrl: URL(string: "https://a.test/0.jpg")!) - let deck = CardDuplicates(reshuffling: [card, card]) - let outcome = GameOutcome(level: .easy, clickCount: 7, cards: deck) - - // Act - connection.accept(.navigateToGameOver(outcome: outcome)) - - // Assert — ImmediateClock fires on the next main-queue cycle - waitForExpectations(timeout: 1) - XCTAssertEqual(receivedOutcome?.clickCount, 7) - connection.dispose() - } -} diff --git a/SwiftIntroTests/Features/Game/Logic/GameEffectTests.swift b/SwiftIntroTests/Features/Game/Logic/GameEffectTests.swift deleted file mode 100644 index cb3d275..0000000 --- a/SwiftIntroTests/Features/Game/Logic/GameEffectTests.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// GameEffectTests.swift -// SwiftIntroTests -// -// Copyright © 2016-2026 SwiftIntro. All rights reserved. -// -// All tests follow the Arrange-Act-Assert (AAA) pattern: -// - Arrange: build a `GameEffect` value (1–3 lines) -// - Act: read `description` (1 line) -// - Assert: compare against the expected string (1 line) -// - -import Foundation -@testable import SwiftIntro -import XCTest - -final class GameEffectTests: XCTestCase { - private let url = URL(string: "https://example.com/img.jpg")! - - // MARK: - flipCard - - func test_description_flipCard_faceUpTrue() { - // Arrange - let effect = GameEffect.flipCard(index: 3, faceUp: true) - - // Act - let description = effect.description - - // Assert - XCTAssertEqual(description, "flipCard(index: 3, faceUp: true)") - } - - func test_description_flipCard_faceUpFalse() { - // Arrange - let effect = GameEffect.flipCard(index: 7, faceUp: false) - - // Act - let description = effect.description - - // Assert - XCTAssertEqual(description, "flipCard(index: 7, faceUp: false)") - } - - func test_description_flipCard_zeroIndex() { - // Arrange - let effect = GameEffect.flipCard(index: 0, faceUp: true) - - // Act - let description = effect.description - - // Assert - XCTAssertEqual(description, "flipCard(index: 0, faceUp: true)") - } - - // MARK: - scheduleFlipBack - - func test_description_scheduleFlipBack_preservesIndices() { - // Arrange - let effect = GameEffect.scheduleFlipBack(index1: 2, index2: 5) - - // Act - let description = effect.description - - // Assert - XCTAssertEqual(description, "scheduleFlipBack(index1: 2, index2: 5)") - } - - func test_description_scheduleFlipBack_preservesOrder() { - // Arrange — swap the indices to verify order is not normalised - let effect = GameEffect.scheduleFlipBack(index1: 5, index2: 2) - - // Act - let description = effect.description - - // Assert - XCTAssertEqual(description, "scheduleFlipBack(index1: 5, index2: 2)") - } - - // MARK: - navigateToGameOver - - func test_description_navigateToGameOver_includesOutcome() { - // Arrange - let outcome = GameOutcome( - level: .easy, - clickCount: 8, - cards: CardDuplicates(reshuffling: makePairedCards(pairs: 3)) - ) - let effect = GameEffect.navigateToGameOver(outcome: outcome) - - // Act - let description = effect.description - - // Assert - let expected = "navigateToGameOver(outcome: \(String(describing: outcome)))" - XCTAssertEqual(description, expected) - } - - func test_description_navigateToGameOver_reflectsOutcomeFields() { - // Arrange - let outcome = GameOutcome( - level: .hard, - clickCount: 42, - cards: CardDuplicates(reshuffling: makePairedCards(pairs: 10)) - ) - let effect = GameEffect.navigateToGameOver(outcome: outcome) - - // Act - let description = effect.description - - // Assert — outcome's own description is "{level} - {clickCount} taps" - XCTAssertTrue( - description.contains("42 taps"), - "Expected description to include the outcome's tap count, got: \(description)" - ) - } - - // MARK: - Helpers - - private func makePairedCards(pairs: Int) -> [Card] { - (0 ..< pairs).flatMap { index -> [Card] in - let card = Card(imageUrl: URL(string: "https://example.com/\(index).jpg")!) - return [card, card] - } - } -} diff --git a/SwiftIntroTests/Features/Game/Logic/GameLogicTests.swift b/SwiftIntroTests/Features/Game/Logic/GameLogicTests.swift deleted file mode 100644 index 099f464..0000000 --- a/SwiftIntroTests/Features/Game/Logic/GameLogicTests.swift +++ /dev/null @@ -1,438 +0,0 @@ -// -// GameLogicTests.swift -// SwiftIntroTests -// -// Copyright © 2016-2026 SwiftIntro. All rights reserved. -// -// All tests follow the Arrange-Act-Assert (AAA) pattern: -// - Arrange: set up the model and any preconditions (1–5 lines) -// - Act: call the pure function under test (1 line) -// - Assert: verify a single observable outcome (1 line where possible) -// - -import MobiusCore -import XCTest -@testable import SwiftIntro - -final class GameLogicTests: XCTestCase { - - // MARK: - Fixtures - - private let urlA = URL(string: "https://example.com/a.jpg")! - private let urlB = URL(string: "https://example.com/b.jpg")! - private let urlC = URL(string: "https://example.com/c.jpg")! - - /// Returns a `GameModel` with three pairs (6 cards): urlA×2, urlB×2, urlC×2. - private func threePairModel() -> GameModel { - let cards = [urlA, urlA, urlB, urlB, urlC, urlC].map { CardModel(card: Card(imageUrl: $0)) } - return GameModel(cards: cards, level: .easy) - } - - /// Returns a `GameModel` with exactly one pair: two cards sharing `urlA`. - private func onePairModel() -> GameModel { - let cards = [urlA, urlA].map { CardModel(card: Card(imageUrl: $0)) } - return GameModel(cards: cards, level: .easy) - } - - // MARK: - Out-of-bounds tap - - func test_cardTapped_outOfBounds_producesNoModelChange() { - // Arrange - let model = threePairModel() - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 99)) - - // Assert - XCTAssertNil(result.model, "Out-of-bounds tap must not change the model") - } - - func test_cardTapped_outOfBounds_producesNoEffects() { - // Arrange - let model = threePairModel() - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 99)) - - // Assert - XCTAssertTrue(result.effects.isEmpty) - } - - // MARK: - Already face-up tap - - func test_cardTapped_alreadyFlipped_producesNoModelChange() { - // Arrange - var model = threePairModel() - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 0)) - - // Assert - XCTAssertNil(result.model, "Tap on a face-up card must not change the model") - } - - func test_cardTapped_alreadyFlipped_producesNoEffects() { - // Arrange - var model = threePairModel() - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 0)) - - // Assert - XCTAssertTrue(result.effects.isEmpty) - } - - // MARK: - Already matched tap - - func test_cardTapped_alreadyMatched_producesNoModelChange() { - // Arrange - var model = threePairModel() - model.cards[0].isMatched = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 0)) - - // Assert - XCTAssertNil(result.model, "Tap on a matched card must not change the model") - } - - func test_cardTapped_alreadyMatched_producesNoEffects() { - // Arrange - var model = threePairModel() - model.cards[0].isMatched = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 0)) - - // Assert - XCTAssertTrue(result.effects.isEmpty) - } - - // MARK: - First card of a turn - - func test_cardTapped_firstCard_incrementsClickCount() { - // Arrange - let model = threePairModel() - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 0)) - - // Assert - XCTAssertEqual(result.model?.clickCount, 1) - } - - func test_cardTapped_firstCard_marksCardFlipped() { - // Arrange - let model = threePairModel() - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 0)) - - // Assert - XCTAssertEqual(result.model?.cards[0].isFlipped, true) - } - - func test_cardTapped_firstCard_storesPendingIndex() { - // Arrange - let model = threePairModel() - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 2)) - - // Assert - XCTAssertEqual(result.model?.pendingCardIndex, 2) - } - - func test_cardTapped_firstCard_emitsSingleFlipCardEffect() { - // Arrange - let model = threePairModel() - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 0)) - - // Assert - XCTAssertEqual(result.effects.count, 1) - } - - func test_cardTapped_firstCard_flipEffectIsFaceUp() { - // Arrange - let model = threePairModel() - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 0)) - - // Assert - guard case .flipCard(let index, let faceUp) = result.effects.first else { - return XCTFail("Expected .flipCard effect") - } - XCTAssertEqual(index, 0) - XCTAssertTrue(faceUp) - } - - // MARK: - Second card, no match - - func test_cardTapped_secondCard_noMatch_clearsPendingIndex() { - // Arrange — urlA (index 0) vs urlB (index 2): no match - var model = threePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 2)) - - // Assert - XCTAssertNil(result.model?.pendingCardIndex) - } - - func test_cardTapped_secondCard_noMatch_emitsTwoEffects() { - // Arrange - var model = threePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 2)) - - // Assert - XCTAssertEqual(result.effects.count, 2) - } - - func test_cardTapped_secondCard_noMatch_includesFlipEffect() { - // Arrange - var model = threePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 2)) - - // Assert - let hasFlip = result.effects.contains { - if case .flipCard(let i, let faceUp) = $0 { return i == 2 && faceUp } - return false - } - XCTAssertTrue(hasFlip, "Expected .flipCard(index: 2, faceUp: true)") - } - - func test_cardTapped_secondCard_noMatch_includesScheduleFlipBackEffect() { - // Arrange - var model = threePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 2)) - - // Assert - let hasSchedule = result.effects.contains { - if case .scheduleFlipBack(let i1, let i2) = $0 { return i1 == 0 && i2 == 2 } - return false - } - XCTAssertTrue(hasSchedule, "Expected .scheduleFlipBack(index1: 0, index2: 2)") - } - - func test_cardTapped_secondCard_noMatch_doesNotIncrementMatches() { - // Arrange - var model = threePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 2)) - - // Assert - XCTAssertEqual(result.model?.matches, 0) - } - - // MARK: - Second card, match (not last pair) - - func test_cardTapped_secondCard_match_incrementsMatchCount() { - // Arrange — urlA at index 0 and urlA at index 1: matching pair - var model = threePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 1)) - - // Assert - XCTAssertEqual(result.model?.matches, 1) - } - - func test_cardTapped_secondCard_match_marksFirstCardAsMatched() { - // Arrange - var model = threePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 1)) - - // Assert - XCTAssertEqual(result.model?.cards[0].isMatched, true) - } - - func test_cardTapped_secondCard_match_marksSecondCardAsMatched() { - // Arrange - var model = threePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 1)) - - // Assert - XCTAssertEqual(result.model?.cards[1].isMatched, true) - } - - func test_cardTapped_secondCard_match_clearsPendingIndex() { - // Arrange - var model = threePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 1)) - - // Assert - XCTAssertNil(result.model?.pendingCardIndex) - } - - func test_cardTapped_secondCard_match_emitsOnlyFlipEffect() { - // Arrange — intermediate match (not the last pair) - var model = threePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 1)) - - // Assert — only a flip effect; no navigation since pairs remain - XCTAssertEqual(result.effects.count, 1) - } - - // MARK: - Last match → game over - - func test_cardTapped_lastMatch_emitsTwoEffects() { - // Arrange — one pair: urlA at index 0 and 1 - var model = onePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 1)) - - // Assert — flipCard + navigateToGameOver - XCTAssertEqual(result.effects.count, 2) - } - - func test_cardTapped_lastMatch_includesNavigateToGameOverEffect() { - // Arrange - var model = onePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 1)) - - // Assert - let hasNavigation = result.effects.contains { - if case .navigateToGameOver = $0 { return true } - return false - } - XCTAssertTrue(hasNavigation, "Expected .navigateToGameOver effect on last match") - } - - func test_cardTapped_lastMatch_navigateOutcomeCarriesLevel() { - // Arrange - let cards = [urlA, urlA].map { CardModel(card: Card(imageUrl: $0)) } - var model = GameModel(cards: cards, level: .hard) - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 1)) - - // Assert - guard case .navigateToGameOver(let outcome) = result.effects.first(where: { - if case .navigateToGameOver = $0 { return true }; return false - }) else { return XCTFail("Expected .navigateToGameOver") } - XCTAssertEqual(outcome.level, .hard) - } - - func test_cardTapped_lastMatch_navigateOutcomeCarriesIncrementedClickCount() { - // Arrange - var model = onePairModel() - model.pendingCardIndex = 0 - model.cards[0].isFlipped = true - model.clickCount = 5 - - // Act - let result = GameLogic.update(model: model, event: .cardTapped(index: 1)) - - // Assert — tap itself increments clickCount before the outcome is built - guard case .navigateToGameOver(let outcome) = result.effects.first(where: { - if case .navigateToGameOver = $0 { return true }; return false - }) else { return XCTFail("Expected .navigateToGameOver") } - XCTAssertEqual(outcome.clickCount, 6) - } - - // MARK: - flipBackCards event - - func test_flipBackCards_marksFirstCardFaceDown() { - // Arrange - var model = threePairModel() - model.cards[0].isFlipped = true - model.cards[2].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .flipBackCards(index1: 0, index2: 2)) - - // Assert - XCTAssertEqual(result.model?.cards[0].isFlipped, false) - } - - func test_flipBackCards_marksSecondCardFaceDown() { - // Arrange - var model = threePairModel() - model.cards[0].isFlipped = true - model.cards[2].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .flipBackCards(index1: 0, index2: 2)) - - // Assert - XCTAssertEqual(result.model?.cards[2].isFlipped, false) - } - - func test_flipBackCards_emitsTwoFlipDownEffects() { - // Arrange - var model = threePairModel() - model.cards[0].isFlipped = true - model.cards[2].isFlipped = true - - // Act - let result = GameLogic.update(model: model, event: .flipBackCards(index1: 0, index2: 2)) - - // Assert - let flipDownCount = result.effects.filter { - if case .flipCard(_, let faceUp) = $0 { return !faceUp } - return false - }.count - XCTAssertEqual(flipDownCount, 2) - } - - func test_flipBackCards_doesNotChangeMatchCount() { - // Arrange - var model = threePairModel() - model.matches = 1 - - // Act - let result = GameLogic.update(model: model, event: .flipBackCards(index1: 0, index2: 2)) - - // Assert - XCTAssertEqual(result.model?.matches, 1) - } -} diff --git a/SwiftIntroTests/Features/Game/Logic/GameLoopTests.swift b/SwiftIntroTests/Features/Game/Logic/GameLoopTests.swift deleted file mode 100644 index ddbf107..0000000 --- a/SwiftIntroTests/Features/Game/Logic/GameLoopTests.swift +++ /dev/null @@ -1,202 +0,0 @@ -// -// GameLoopTests.swift -// SwiftIntroTests -// -// Copyright © 2016-2026 SwiftIntro. All rights reserved. -// -// All tests follow the Arrange-Act-Assert (AAA) pattern: -// - Arrange: build a GameLoop with a known initial model (1–5 lines) -// - Act: call the method under test (1 line) -// - Assert: verify a single observable outcome (1 line) -// - -import MobiusCore -@testable import SwiftIntro -import UIKit -import XCTest - -final class GameLoopTests: XCTestCase { - // MARK: - Helpers - - private func makeCard(url: URL = URL(string: "https://a.test/img.jpg")!) -> CardModel { - CardModel(card: Card(imageUrl: url)) - } - - private func makeModel(level: Level = .easy) -> GameModel { - let cards = (0 ..< level.cardCount).map { i in - CardModel(card: Card(imageUrl: URL(string: "https://a.test/\(i).jpg")!)) - } - return GameModel(cards: cards, level: level) - } - - // MARK: - init - - func test_init_exposesCorrectLevel() { - // Arrange - let model = makeModel(level: .normal) - - // Act - let loop = GameLoop(initialModel: model) - - // Assert - XCTAssertEqual(loop.level, .normal) - } - - func test_init_levelMatchesEasy() { - // Arrange - let model = makeModel(level: .easy) - - // Act - let loop = GameLoop(initialModel: model) - - // Assert - XCTAssertEqual(loop.level, .easy) - } - - func test_init_levelMatchesHard() { - // Arrange - let model = makeModel(level: .hard) - - // Act - let loop = GameLoop(initialModel: model) - - // Assert - XCTAssertEqual(loop.level, .hard) - } - - // MARK: - canSelectCard - - func test_canSelectCard_returnsTrueForUnmatchedCard() { - // Arrange - let model = makeModel(level: .easy) - let loop = GameLoop(initialModel: model) - - // Act - let result = loop.canSelectCard(at: 0) - - // Assert - XCTAssertTrue(result) - } - - func test_canSelectCard_returnsFalseAfterUpdate() { - // Arrange - var model = makeModel(level: .easy) - let loop = GameLoop(initialModel: model) - - // Act — mark first card matched and push the update through - model.cards[0].isMatched = true - loop.update(with: model) - - // Assert - XCTAssertFalse(loop.canSelectCard(at: 0)) - } - - // MARK: - update(with:) - - func test_update_doesNotCrash() { - // Arrange - let model = makeModel(level: .easy) - let loop = GameLoop(initialModel: model) - - // Act + Assert - XCTAssertNoThrow(loop.update(with: model)) - } - - // MARK: - start / stop - - func test_start_doesNotCrash() { - // Arrange - let loop = GameLoop(initialModel: makeModel()) - let view = AnyConnectable { _ in - Connection(acceptClosure: { _ in }, disposeClosure: {}) - } - let cv = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) - - // Act + Assert - XCTAssertNoThrow(loop.start(view: view, collectionView: cv, onNavigateToGameOver: { _ in })) - loop.stop() - } - - func test_stop_afterStart_doesNotCrash() { - // Arrange - let loop = GameLoop(initialModel: makeModel()) - let view = AnyConnectable { _ in - Connection(acceptClosure: { _ in }, disposeClosure: {}) - } - let cv = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) - loop.start(view: view, collectionView: cv, onNavigateToGameOver: { _ in }) - - // Act + Assert - XCTAssertNoThrow(loop.stop()) - } - - func test_start_deliversInitialModelToView() { - // Arrange - let model = makeModel(level: .easy) - let loop = GameLoop(initialModel: model) - let exp = expectation(description: "model delivered to view") - exp.assertForOverFulfill = false - var receivedModel: GameModel? - let view = AnyConnectable { _ in - Connection( - acceptClosure: { model in - receivedModel = model - exp.fulfill() - }, - disposeClosure: {} - ) - } - let cv = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) - - // Act - loop.start(view: view, collectionView: cv, onNavigateToGameOver: { _ in }) - - // Assert - waitForExpectations(timeout: 1) - XCTAssertEqual(receivedModel?.cards.count, model.cards.count) - loop.stop() - } - - func test_start_onNavigateToGameOver_isStoredAndCallable() { - // Arrange - let loop = GameLoop(initialModel: makeModel()) - let view = AnyConnectable { _ in - Connection(acceptClosure: { _ in }, disposeClosure: {}) - } - let cv = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) - var navigateCalled = false - - // Act - loop.start(view: view, collectionView: cv, onNavigateToGameOver: { _ in navigateCalled = true }) - - // Assert — just verify start ran without crash and the closure was captured - XCTAssertFalse(navigateCalled) - loop.stop() - } - - // MARK: - configureCell - - func test_configureCell_doesNotCrashBeforeStart() { - // Arrange — loop not started; effectHandler is pre-seeded with initial model - let loop = GameLoop(initialModel: makeModel()) - let cell = CardCVCell(frame: CGRect(origin: .zero, size: CGSize(width: 80, height: 100))) - - // Act + Assert - XCTAssertNoThrow(loop.configureCell(cell, at: 0)) - } - - func test_configureCell_doesNotCrashAfterStart() { - // Arrange - let loop = GameLoop(initialModel: makeModel()) - let view = AnyConnectable { _ in - Connection(acceptClosure: { _ in }, disposeClosure: {}) - } - let cv = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) - loop.start(view: view, collectionView: cv, onNavigateToGameOver: { _ in }) - let cell = CardCVCell(frame: CGRect(origin: .zero, size: CGSize(width: 80, height: 100))) - - // Act + Assert - XCTAssertNoThrow(loop.configureCell(cell, at: 0)) - loop.stop() - } -} diff --git a/SwiftIntroTests/Features/Game/View/CardGridLayoutTests.swift b/SwiftIntroTests/Features/Game/View/CardGridLayoutTests.swift new file mode 100644 index 0000000..b68ec22 --- /dev/null +++ b/SwiftIntroTests/Features/Game/View/CardGridLayoutTests.swift @@ -0,0 +1,74 @@ +// +// CardGridLayoutTests.swift +// SwiftIntroTests +// +// Copyright © 2016-2026 SwiftIntro. All rights reserved. +// + +@testable import SwiftIntro +import UIKit +import XCTest + +final class CardGridLayoutTests: XCTestCase { + private func makeFlow( + lineSpacing: CGFloat = 8, + itemSpacing: CGFloat = 8, + insets: UIEdgeInsets = .zero + ) -> UICollectionViewFlowLayout { + let layout = UICollectionViewFlowLayout() + layout.minimumLineSpacing = lineSpacing + layout.minimumInteritemSpacing = itemSpacing + layout.sectionInset = insets + return layout + } + + // MARK: - squareSide + + func test_squareSide_withSquareBounds_returnsSameForWidthAndHeight() { + // Arrange — 3 rows × 3 columns, square bounds, zero spacing + let layout = CardGridLayout(rows: 3, columns: 3) + let flow = makeFlow(lineSpacing: 0, itemSpacing: 0) + + // Act + let side = layout.squareSide(in: CGSize(width: 300, height: 300), flowLayout: flow) + + // Assert + XCTAssertEqual(side, 100) + } + + func test_squareSide_widerThanTall_limitsByHeight() { + // Arrange — 2 rows × 5 columns; wider space but only 2 rows + let layout = CardGridLayout(rows: 2, columns: 5) + let flow = makeFlow(lineSpacing: 0, itemSpacing: 0) + + // Act + let side = layout.squareSide(in: CGSize(width: 1000, height: 200), flowLayout: flow) + + // Assert — height / 2 = 100 is the limit + XCTAssertEqual(side, 100) + } + + func test_squareSide_subtractsSpacing() { + // Arrange — 2 rows × 2 columns with 10-pt gaps + let layout = CardGridLayout(rows: 2, columns: 2) + let flow = makeFlow(lineSpacing: 10, itemSpacing: 10) + + // Act — (200 - 10) / 2 = 95 + let side = layout.squareSide(in: CGSize(width: 200, height: 200), flowLayout: flow) + + // Assert + XCTAssertEqual(side, 95) + } + + func test_squareSide_truncatesSubPixelValues() { + // Arrange — 3 rows, height 100 / 3 = 33.33 → trunc = 33 + let layout = CardGridLayout(rows: 3, columns: 3) + let flow = makeFlow(lineSpacing: 0, itemSpacing: 0) + + // Act + let side = layout.squareSide(in: CGSize(width: 100, height: 100), flowLayout: flow) + + // Assert + XCTAssertEqual(side, 33) + } +} diff --git a/SwiftIntroTests/Features/Game/View/GameViewTests.swift b/SwiftIntroTests/Features/Game/View/GameViewTests.swift new file mode 100644 index 0000000..0597f1c --- /dev/null +++ b/SwiftIntroTests/Features/Game/View/GameViewTests.swift @@ -0,0 +1,47 @@ +// +// GameViewTests.swift +// SwiftIntroTests +// +// Copyright © 2016-2026 SwiftIntro. All rights reserved. +// + +@testable import SwiftIntro +import UIKit +import XCTest + +final class GameViewTests: XCTestCase { + private func makeCard(_ index: Int) -> Card { + Card(imageUrl: URL(string: "https://a.test/\(index).jpg")!) + } + + // MARK: - animateFlip + + func test_animateFlip_visibleCell_doesNotCrash() { + // Arrange — mount the view in a window so the collection view lays out cells + let ds = MemoryDataSourceAndDelegate( + rows: 3, + columns: 2, + canSelectCard: { _ in true }, + configureCell: { _, _ in }, + onCardTapped: { _ in } + ) + let view = GameView(collectionViewDataSource: ds, collectionViewDelegate: ds) + view.frame = CGRect(x: 0, y: 0, width: 300, height: 500) + let window = UIWindow(frame: view.frame) + window.addSubview(view) + window.makeKeyAndVisible() + view.layoutIfNeeded() + + // Act + Assert — cell is visible, flip path runs without hitting the + // off-screen guard and the warning log. + XCTAssertNoThrow(view.animateFlip(at: IndexPath(item: 0, section: 0), isFaceUp: true)) + } + + func test_animateFlip_offScreen_doesNotCrash() { + // Arrange — no window, no layout, nothing dequeued + let view = GameView() + + // Act + Assert — guard-let fall-through logs and returns without crashing + XCTAssertNoThrow(view.animateFlip(at: IndexPath(item: 0, section: 0), isFaceUp: true)) + } +} diff --git a/SwiftIntroTests/Features/Game/View/MemoryDataSourceAndDelegateTests.swift b/SwiftIntroTests/Features/Game/View/MemoryDataSourceAndDelegateTests.swift index 274858b..f1af963 100644 --- a/SwiftIntroTests/Features/Game/View/MemoryDataSourceAndDelegateTests.swift +++ b/SwiftIntroTests/Features/Game/View/MemoryDataSourceAndDelegateTests.swift @@ -19,9 +19,18 @@ final class MemoryDataSourceAndDelegateTests: XCTestCase { private func makeDS( rows: Int = 2, - columns: Int = 3 + columns: Int = 3, + canSelectCard: @escaping MemoryDataSourceAndDelegate.CanSelectCard = { _ in false }, + configureCell: @escaping MemoryDataSourceAndDelegate.ConfigureCell = { _, _ in }, + onCardTapped: @escaping MemoryDataSourceAndDelegate.OnCardTapped = { _ in } ) -> MemoryDataSourceAndDelegate { - MemoryDataSourceAndDelegate(rows: rows, columns: columns) + MemoryDataSourceAndDelegate( + rows: rows, + columns: columns, + canSelectCard: canSelectCard, + configureCell: configureCell, + onCardTapped: onCardTapped + ) } private func makeCV( @@ -87,11 +96,14 @@ final class MemoryDataSourceAndDelegateTests: XCTestCase { func test_didSelectItemAt_callsOnCardTapped_whenCanSelect() { // Arrange - let ds = makeDS(rows: 2, columns: 3) - let (cv, _) = makeCV() var tappedIndex: Int? - ds.canSelectCard = { _ in true } - ds.onCardTapped = { tappedIndex = $0 } + let ds = makeDS( + rows: 2, + columns: 3, + canSelectCard: { _ in true }, + onCardTapped: { tappedIndex = $0 } + ) + let (cv, _) = makeCV() // Act — section=0, item=1 → flatIndex = 0*3 + 1 = 1 ds.collectionView(cv, didSelectItemAt: IndexPath(item: 1, section: 0)) @@ -102,29 +114,17 @@ final class MemoryDataSourceAndDelegateTests: XCTestCase { func test_didSelectItemAt_doesNotCallOnCardTapped_whenCannotSelect() { // Arrange - let ds = makeDS(rows: 2, columns: 3) - let (cv, _) = makeCV() var tappedIndex: Int? - ds.canSelectCard = { _ in false } - ds.onCardTapped = { tappedIndex = $0 } - - // Act - ds.collectionView(cv, didSelectItemAt: IndexPath(item: 2, section: 1)) - - // Assert - XCTAssertNil(tappedIndex) - } - - func test_didSelectItemAt_doesNotCallOnCardTapped_whenCanSelectIsNil() { - // Arrange - let ds = makeDS(rows: 2, columns: 3) + let ds = makeDS( + rows: 2, + columns: 3, + canSelectCard: { _ in false }, + onCardTapped: { tappedIndex = $0 } + ) let (cv, _) = makeCV() - var tappedIndex: Int? - // canSelectCard deliberately not set - ds.onCardTapped = { tappedIndex = $0 } // Act - ds.collectionView(cv, didSelectItemAt: IndexPath(item: 0, section: 0)) + ds.collectionView(cv, didSelectItemAt: IndexPath(item: 2, section: 1)) // Assert XCTAssertNil(tappedIndex) @@ -132,11 +132,14 @@ final class MemoryDataSourceAndDelegateTests: XCTestCase { func test_didSelectItemAt_flatIndexIsCorrect() { // Arrange — rows=3, columns=4 → section=2, item=3 → flatIndex = 2*4+3 = 11 - let ds = makeDS(rows: 3, columns: 4) - let (cv, _) = makeCV() var tappedIndex: Int? - ds.canSelectCard = { _ in true } - ds.onCardTapped = { tappedIndex = $0 } + let ds = makeDS( + rows: 3, + columns: 4, + canSelectCard: { _ in true }, + onCardTapped: { tappedIndex = $0 } + ) + let (cv, _) = makeCV() // Act ds.collectionView(cv, didSelectItemAt: IndexPath(item: 3, section: 2)) @@ -149,10 +152,13 @@ final class MemoryDataSourceAndDelegateTests: XCTestCase { func test_willDisplay_callsConfigureCell_withCorrectIndex() { // Arrange — section=0, item=2 → flatIndex = 0*3+2 = 2 - let ds = makeDS(rows: 2, columns: 3) - let (cv, _) = makeCV() var configuredIndex: Int? - ds.configureCell = { _, index in configuredIndex = index } + let ds = makeDS( + rows: 2, + columns: 3, + configureCell: { _, index in configuredIndex = index } + ) + let (cv, _) = makeCV() let cell = CardCVCell(frame: CGRect(origin: .zero, size: CGSize(width: 80, height: 100))) // Act @@ -164,10 +170,9 @@ final class MemoryDataSourceAndDelegateTests: XCTestCase { func test_willDisplay_doesNotCallConfigureCell_forNonCardCVCell() { // Arrange - let ds = makeDS() - let (cv, _) = makeCV() var configuredIndex: Int? - ds.configureCell = { _, index in configuredIndex = index } + let ds = makeDS(configureCell: { _, index in configuredIndex = index }) + let (cv, _) = makeCV() let cell = UICollectionViewCell() // not a CardCVCell // Act @@ -179,10 +184,13 @@ final class MemoryDataSourceAndDelegateTests: XCTestCase { func test_willDisplay_flatIndexIsCorrect() { // Arrange — rows=2, columns=3 → section=1, item=2 → flatIndex = 1*3+2 = 5 - let ds = makeDS(rows: 2, columns: 3) - let (cv, _) = makeCV() var configuredIndex: Int? - ds.configureCell = { _, index in configuredIndex = index } + let ds = makeDS( + rows: 2, + columns: 3, + configureCell: { _, index in configuredIndex = index } + ) + let (cv, _) = makeCV() let cell = CardCVCell(frame: CGRect(origin: .zero, size: CGSize(width: 80, height: 100))) // Act diff --git a/SwiftIntroTests/Features/GameOver/GameOverVCTests.swift b/SwiftIntroTests/Features/GameOver/GameOverVCTests.swift index 2c6af9a..d0df380 100644 --- a/SwiftIntroTests/Features/GameOver/GameOverVCTests.swift +++ b/SwiftIntroTests/Features/GameOver/GameOverVCTests.swift @@ -57,23 +57,31 @@ final class GameOverVCTests: XCTestCase { clickCount: Int = 5 ) -> GameOutcome { GameOutcome( - level: level, + config: GameConfiguration(level: level), clickCount: clickCount, cards: makeCards(count: level.cardCount) ) } private func makeVC( - config: GameConfiguration = GameConfiguration(level: .easy), outcome: GameOutcome? = nil ) -> GameOverVC { - GameOverVC(config: config, outcome: outcome ?? makeOutcome()) - } - - /// Casts `vc.view` to `GameOverView`. Crashes the test if the type is wrong. - private func gameOverView(of vc: GameOverVC) -> GameOverView { - // swiftlint:disable:next force_cast - vc.view as! GameOverView + GameOverVC(outcome: outcome ?? makeOutcome()) + } + + /// Casts `vc.view` to `GameOverView`. Throws if the type is wrong so the test + /// terminates immediately rather than continuing with a stand-in instance. + private func gameOverView( + of vc: GameOverVC, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> GameOverView { + try XCTUnwrap( + vc.view as? GameOverView, + "Expected vc.view to be GameOverView, got \(type(of: vc.view))", + file: file, + line: line + ) } /// Recursively collects every `CircularButton` in the view hierarchy, depth-first. @@ -102,7 +110,7 @@ final class GameOverVCTests: XCTestCase { // MARK: - viewDidLoad - func test_viewDidLoad_wiresOnRestart() { + func test_viewDidLoad_wiresOnRestart() throws { // Arrange let vc = makeVC() @@ -110,10 +118,10 @@ final class GameOverVCTests: XCTestCase { _ = vc.view // Assert - XCTAssertNotNil(gameOverView(of: vc).onRestart) + XCTAssertNotNil(try gameOverView(of: vc).onRestart) } - func test_viewDidLoad_wiresOnQuit() { + func test_viewDidLoad_wiresOnQuit() throws { // Arrange let vc = makeVC() @@ -121,12 +129,12 @@ final class GameOverVCTests: XCTestCase { _ = vc.view // Assert - XCTAssertNotNil(gameOverView(of: vc).onQuit) + XCTAssertNotNil(try gameOverView(of: vc).onQuit) } // MARK: - onQuit - func test_onQuit_callsNavigatorQuitGame() { + func test_onQuit_callsNavigatorQuitGame() throws { // Arrange let vc = makeVC() let spy = SpyNavigator() @@ -134,24 +142,24 @@ final class GameOverVCTests: XCTestCase { _ = vc.view // Act - gameOverView(of: vc).onQuit?() + try gameOverView(of: vc).onQuit?() // Assert XCTAssertTrue(spy.quitGameCalled) } - func test_onQuit_withoutNavigator_doesNotCrash() { + func test_onQuit_withoutNavigator_doesNotCrash() throws { // Arrange — navigator intentionally not set let vc = makeVC() _ = vc.view // Act + Assert - XCTAssertNoThrow(gameOverView(of: vc).onQuit?()) + XCTAssertNoThrow(try gameOverView(of: vc).onQuit?()) } // MARK: - onRestart - func test_onRestart_callsNavigatorRestartGame() { + func test_onRestart_callsNavigatorRestartGame() throws { // Arrange let vc = makeVC() let spy = SpyNavigator() @@ -159,13 +167,13 @@ final class GameOverVCTests: XCTestCase { _ = vc.view // Act - gameOverView(of: vc).onRestart?() + try gameOverView(of: vc).onRestart?() // Assert XCTAssertTrue(spy.restartGameCalled) } - func test_onRestart_passesOutcomeCardsToNavigator() { + func test_onRestart_passesOutcomeCardsToNavigator() throws { // Arrange let outcome = makeOutcome(level: .easy) let vc = makeVC(outcome: outcome) @@ -174,46 +182,48 @@ final class GameOverVCTests: XCTestCase { _ = vc.view // Act - gameOverView(of: vc).onRestart?() + try gameOverView(of: vc).onRestart?() // Assert XCTAssertEqual(spy.lastGame?.cards.memoryCards.count, outcome.cards.memoryCards.count) } - func test_onRestart_withoutNavigator_doesNotCrash() { + func test_onRestart_withoutNavigator_doesNotCrash() throws { // Arrange — navigator intentionally not set let vc = makeVC() _ = vc.view // Act + Assert - XCTAssertNoThrow(gameOverView(of: vc).onRestart?()) + XCTAssertNoThrow(try gameOverView(of: vc).onRestart?()) } // MARK: - @objc button targets - func test_restartButton_invokesOnRestartClosure() { + func test_restartButton_invokesOnRestartClosure() throws { // Arrange let vc = makeVC() _ = vc.view + let view = try gameOverView(of: vc) var restartCalled = false - gameOverView(of: vc).onRestart = { restartCalled = true } + view.onRestart = { restartCalled = true } // Act — trigger the @objc target-action registered on the restart button - findCircularButtons(in: gameOverView(of: vc)).first?.sendActions(for: .touchUpInside) + findCircularButtons(in: view).first?.sendActions(for: .touchUpInside) // Assert XCTAssertTrue(restartCalled) } - func test_quitButton_invokesOnQuitClosure() { + func test_quitButton_invokesOnQuitClosure() throws { // Arrange let vc = makeVC() _ = vc.view + let view = try gameOverView(of: vc) var quitCalled = false - gameOverView(of: vc).onQuit = { quitCalled = true } + view.onQuit = { quitCalled = true } // Act — trigger the @objc target-action registered on the quit button - findCircularButtons(in: gameOverView(of: vc)).last?.sendActions(for: .touchUpInside) + findCircularButtons(in: view).last?.sendActions(for: .touchUpInside) // Assert XCTAssertTrue(quitCalled) diff --git a/SwiftIntroTests/Features/GameOver/GameOverViewSnapshotTests.swift b/SwiftIntroTests/Features/GameOver/GameOverViewSnapshotTests.swift index 07b4759..8bce53e 100644 --- a/SwiftIntroTests/Features/GameOver/GameOverViewSnapshotTests.swift +++ b/SwiftIntroTests/Features/GameOver/GameOverViewSnapshotTests.swift @@ -21,7 +21,11 @@ final class GameOverViewSnapshotTests: XCTestCase { return [card, card] } let deck = CardDuplicates(reshuffling: paired) - return GameOutcome(level: level, clickCount: clickCount, cards: deck) + return GameOutcome( + config: GameConfiguration(level: level), + clickCount: clickCount, + cards: deck + ) } func test_gameOverView_defaultAppearance() { diff --git a/SwiftIntroTests/Features/Loading/LoadingVCTests.swift b/SwiftIntroTests/Features/Loading/LoadingVCTests.swift index 05af485..717f2eb 100644 --- a/SwiftIntroTests/Features/Loading/LoadingVCTests.swift +++ b/SwiftIntroTests/Features/Loading/LoadingVCTests.swift @@ -270,9 +270,58 @@ final class LoadingVCTests: XCTestCase { XCTAssertEqual(spy.navigateToGameCallCount, 0) } + // MARK: - retry button @objc target + + func test_retryButton_invokesOnRetryClosure() throws { + // Arrange — view is loaded so the retry button target is wired + apiStub.result = .failure(URLError(.unknown)) + let vc = makeVC() + _ = vc.view + let loadingView = try XCTUnwrap(vc.view as? LoadingView) + var retryCalled = false + loadingView.onRetry = { retryCalled = true } + + // Act — send the same action UIKit fires on tap so @objc retryTapped runs + let retryButton = firstButton(in: loadingView) + retryButton?.sendActions(for: .touchUpInside) + + // Assert + XCTAssertTrue(retryCalled) + } + + /// Depth-first search for the first `UIButton` subview — LoadingView's retry button is private. + private func firstButton(in view: UIView) -> UIButton? { + if let btn = view as? UIButton { return btn } + for sub in view.subviews { + if let btn = firstButton(in: sub) { return btn } + } + return nil + } + + // MARK: - viewDidDisappear + + func test_viewDidDisappear_stopsViewModel_clearsNavigationCallback() { + // Arrange — navigator set, fetch pending; disappearing before it completes must + // drop the navigation callback so the ex-navigator is never invoked. + apiStub.result = .success(makeCards(count: 3)) + let vc = makeVC() + let spy = SpyNavigator() + vc.navigator = spy + _ = vc.view + + // Act — disappear immediately, then drain + vc.viewDidDisappear(false) + let drain = expectation(description: "main queue drain") + DispatchQueue.main.async { drain.fulfill() } + waitForExpectations(timeout: 1) + + // Assert — stop() cleared the callback so navigation never fires + XCTAssertEqual(spy.navigateToGameCallCount, 0) + } + // MARK: - retry - func test_retry_afterFailure_callsFindImagesAgain() { + func test_retry_afterFailure_callsFindImagesAgain() throws { // Arrange — first call fails, second call succeeds apiStub.result = .failure(URLError(.unknown)) let vc = makeVC(query: "cats") @@ -293,8 +342,8 @@ final class LoadingVCTests: XCTestCase { spy.onNavigateToGame = { navExp.fulfill() } // Act — simulate the retry button tap via onRetry on the LoadingView - // swiftlint:disable:next force_cast - (vc.view as! LoadingView).onRetry?() + let loadingView = try XCTUnwrap(vc.view as? LoadingView, "Expected vc.view to be LoadingView") + loadingView.onRetry?() // Assert waitForExpectations(timeout: 1) diff --git a/SwiftIntroTests/Features/Loading/LoadingViewModelTests.swift b/SwiftIntroTests/Features/Loading/LoadingViewModelTests.swift new file mode 100644 index 0000000..36312d9 --- /dev/null +++ b/SwiftIntroTests/Features/Loading/LoadingViewModelTests.swift @@ -0,0 +1,134 @@ +// +// LoadingViewModelTests.swift +// SwiftIntroTests +// +// Copyright © 2016-2026 SwiftIntro. All rights reserved. +// + +import Factory +@testable import SwiftIntro +import UIKit +import XCTest + +// MARK: - Stubs + +private final class StubWikimediaClient: WikimediaClientProtocol { + var result: Result = .failure(URLError(.unknown)) + + func findImages( + with _: String, + done: @escaping (Result) -> Void + ) { + done(result) + } +} + +private final class StubImageCache: ImageCacheProtocol { + func prefetchImages( + _: [URL], + done: Closure? + ) { + done?() + } +} + +// MARK: - Tests + +final class LoadingViewModelTests: XCTestCase { + private var apiStub: StubWikimediaClient! + private var cacheStub: StubImageCache! + + override func setUp() { + super.setUp() + apiStub = StubWikimediaClient() + cacheStub = StubImageCache() + let apiStub = apiStub! + let cacheStub = cacheStub! + Container.shared.wikimediaClient.register { apiStub } + Container.shared.imageCache.register { cacheStub } + } + + override func tearDown() { + Container.shared.wikimediaClient.reset() + Container.shared.imageCache.reset() + apiStub = nil + cacheStub = nil + super.tearDown() + } + + private func makeCards(count: Int) -> CardSingles { + CardSingles(cards: (0 ..< count).map { Card(imageUrl: URL(string: "https://a.test/\($0).jpg")!) }) + } + + // MARK: - Phase.description + + func test_phaseDescription_loading_isLoading() { + // Act + Assert + XCTAssertEqual(String(describing: LoadingViewModel.Phase.loading), "Loading") + } + + func test_phaseDescription_failed_containsFailedPrefix() { + // Act + let description = String(describing: LoadingViewModel.Phase.failed(URLError(.unknown))) + + // Assert + XCTAssertTrue(description.hasPrefix("Failed:")) + } + + // MARK: - stop + + func test_stop_clearsCallbacks_navigationDoesNotFire() { + // Arrange — delay the prefetch completion so stop() runs before it fires + final class DelayingCache: ImageCacheProtocol { + var pending: Closure? + func prefetchImages( + _: [URL], + done: Closure? + ) { pending = done } + } + let delaying = DelayingCache() + Container.shared.imageCache.register { delaying } + apiStub.result = .success(makeCards(count: 3)) + let vm = LoadingViewModel(config: GameConfiguration(level: .easy)) + var navigateCount = 0 + vm.start( + onPhaseChange: { _ in }, + onNavigateToGame: { _ in navigateCount += 1 } + ) + // Drain the async dispatch so handleFetchSuccess runs and pending is set. + let drain = expectation(description: "drain") + DispatchQueue.main.async { drain.fulfill() } + waitForExpectations(timeout: 1) + + // Act — stop clears the callback; firing the pending prefetch hits the + // nil-navigation branch instead of notifying. + vm.stop() + delaying.pending?() + + // Assert + XCTAssertEqual(navigateCount, 0) + } + + // MARK: - retry + + func test_retry_callsFindImagesAgain() { + // Arrange + apiStub.result = .failure(URLError(.unknown)) + let vm = LoadingViewModel(config: GameConfiguration(level: .easy)) + vm.start(onPhaseChange: { _ in }, onNavigateToGame: { _ in }) + + // Drain the first failing fetch + let firstDrain = expectation(description: "first drain") + DispatchQueue.main.async { firstDrain.fulfill() } + waitForExpectations(timeout: 1) + + // Act — flip to success and retry + apiStub.result = .success(makeCards(count: 3)) + let navExp = expectation(description: "navigate after retry") + vm.onNavigateToGame = { _ in navExp.fulfill() } + vm.retry() + + // Assert + waitForExpectations(timeout: 1) + } +} diff --git a/SwiftIntroTests/Models/CardSinglesTests.swift b/SwiftIntroTests/Models/CardSinglesTests.swift new file mode 100644 index 0000000..b9fd353 --- /dev/null +++ b/SwiftIntroTests/Models/CardSinglesTests.swift @@ -0,0 +1,67 @@ +// +// CardSinglesTests.swift +// SwiftIntroTests +// +// Copyright © 2016-2026 SwiftIntro. All rights reserved. +// + +@testable import SwiftIntro +import XCTest + +final class CardSinglesTests: XCTestCase { + private func makeCard(_ index: Int) -> Card { + Card(imageUrl: URL(string: "https://example.com/\(index).jpg")!) + } + + // MARK: - == + + func test_equal_sameCardsSameOrder_areEqual() { + // Arrange + let a = CardSingles(cards: [makeCard(1), makeCard(2)]) + let b = CardSingles(cards: [makeCard(1), makeCard(2)]) + + // Act + Assert + XCTAssertEqual(a, b) + } + + func test_equal_sameCardsDifferentOrder_areEqual() { + // Arrange — equality is set-based, order-insensitive + let a = CardSingles(cards: [makeCard(1), makeCard(2)]) + let b = CardSingles(cards: [makeCard(2), makeCard(1)]) + + // Act + Assert + XCTAssertEqual(a, b) + } + + func test_equal_differentCards_areNotEqual() { + // Arrange + let a = CardSingles(cards: [makeCard(1), makeCard(2)]) + let b = CardSingles(cards: [makeCard(1), makeCard(3)]) + + // Act + Assert + XCTAssertNotEqual(a, b) + } + + // MARK: - hash(into:) + + func test_hash_equalValues_produceEqualHashes() { + // Arrange + let a = CardSingles(cards: [makeCard(1), makeCard(2)]) + let b = CardSingles(cards: [makeCard(2), makeCard(1)]) + + // Act + Assert — order-insensitive hashing matches equality + XCTAssertEqual(a.hashValue, b.hashValue) + } + + func test_hash_usedInSet_dedupesEqualValues() { + // Arrange + let a = CardSingles(cards: [makeCard(1), makeCard(2)]) + let b = CardSingles(cards: [makeCard(2), makeCard(1)]) + + // Act + let set: Set = [a, b] + + // Assert + XCTAssertEqual(set.count, 1) + } +} diff --git a/SwiftIntroTests/Views/SingleCellTypeCollectionViewTests.swift b/SwiftIntroTests/Views/SingleCellTypeCollectionViewTests.swift new file mode 100644 index 0000000..5e93521 --- /dev/null +++ b/SwiftIntroTests/Views/SingleCellTypeCollectionViewTests.swift @@ -0,0 +1,112 @@ +// +// SingleCellTypeCollectionViewTests.swift +// SwiftIntroTests +// +// Copyright © 2016-2026 SwiftIntro. All rights reserved. +// + +@testable import SwiftIntro +import UIKit +import XCTest + +final class SingleCellTypeCollectionViewTests: XCTestCase { + // MARK: - make + + func apa() { + let string = "A cool string" + if string != "A cool string" { + print("Not A cool string") + } + } + + func test_make_returnsInstanceOfExpectedType() { + // Act + let cv = SingleCellTypeCollectionView.make() + + // Assert + XCTAssertNotNil(cv) + } + + func test_make_registersCardCVCellForDequeue() { + // Arrange — attach to a window so dequeue is allowed + let cv = SingleCellTypeCollectionView.make() + cv.frame = CGRect(x: 0, y: 0, width: 200, height: 200) + let ds = ConstantDataSource() + cv.dataSource = ds + let window = UIWindow(frame: cv.frame) + window.addSubview(cv) + window.makeKeyAndVisible() + cv.reloadData() + cv.layoutIfNeeded() + + // Act + let cell = cv.dequeueReusableCell(at: IndexPath(item: 0, section: 0)) + + // Assert + XCTAssertNotNil(cell as CardCVCell) + } + + // MARK: - cellForItemAt + + func test_cellForItemAt_returnsTypedCell_whenVisible() { + // Arrange + let cv = SingleCellTypeCollectionView.make() + cv.frame = CGRect(x: 0, y: 0, width: 200, height: 200) + let ds = ConstantDataSource() + cv.dataSource = ds + let window = UIWindow(frame: cv.frame) + window.addSubview(cv) + window.makeKeyAndVisible() + cv.reloadData() + cv.layoutIfNeeded() + + // Act + let cell = cv.cellForItemAt(IndexPath(item: 0, section: 0)) + + // Assert + XCTAssertNotNil(cell) + } + + func test_cellForItemAt_returnsNil_whenNotVisible() { + // Arrange — no layout, no cells dequeued + let cv = SingleCellTypeCollectionView.make() + + // Act + let cell = cv.cellForItemAt(IndexPath(item: 0, section: 0)) + + // Assert + XCTAssertNil(cell) + } + + // MARK: - reuseIdentifier + + func test_reuseIdentifier_matchesCellIdentifier() { + // Act + Assert + XCTAssertEqual( + SingleCellTypeCollectionView.reuseIdentifier, + CardCVCell.cellIdentifier + ) + } +} + +// MARK: - Test helpers + +/// Minimal data source that reports one section of one cell using the typed dequeue helper. +private final class ConstantDataSource: NSObject, UICollectionViewDataSource { + func numberOfSections(in _: UICollectionView) -> Int { 1 } + + func collectionView( + _: UICollectionView, + numberOfItemsInSection _: Int + ) -> Int { 1 } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + guard let typed = collectionView as? SingleCellTypeCollectionView else { + return UICollectionViewCell() + } + return typed.dequeueReusableCell(at: indexPath) + } +} diff --git a/codecov.yml b/codecov.yml index aed44c4..28a7751 100644 --- a/codecov.yml +++ b/codecov.yml @@ -28,3 +28,9 @@ comment: layout: "reach,diff,flags,files" behavior: default require_changes: true # Only comment when coverage actually changes + +# Test code is excluded from coverage measurement — coverage of the tests +# themselves is meaningless (tests don't test their own assertions), and +# letting them count would artificially deflate or inflate the score. +ignore: + - "SwiftIntroTests/**" From dfaf5c25cdc8c105be06e25dcee280695ca10b32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 09:35:56 +0000 Subject: [PATCH 2/2] address fourth round of Copilot review comments - GameViewModel.handleMatch: build a local copy and assign `model` once instead of mutating it three times (two isMatched flags + matches counter), so observers receive one coherent snapshot per matching pair instead of three intermediate updates - GameViewModel.flipBackCards: same treatment for the two isFlipped resets so the flip-back timer never briefly shows one card face-up and one face-down to observers - SingleCellTypeCollectionViewTests: remove the stray `apa()` helper (debug leftover with print and tab indentation that didn't belong in the test class) - Skipped Copilot's LoadingViewModel comment about implicit self in handleFetchSuccess: with `guard let self else { return }` Swift 5.7+ rebinds self for the rest of the closure, so accessing `onNavigateToGame` and `config` without explicit `self.` is well-formed; CI confirms the file compiles --- SwiftIntro/Features/Game/GameViewModel.swift | 21 ++++++++++++++----- .../SingleCellTypeCollectionViewTests.swift | 7 ------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/SwiftIntro/Features/Game/GameViewModel.swift b/SwiftIntro/Features/Game/GameViewModel.swift index 6d810d2..490f0b5 100644 --- a/SwiftIntro/Features/Game/GameViewModel.swift +++ b/SwiftIntro/Features/Game/GameViewModel.swift @@ -197,13 +197,18 @@ private extension GameViewModel { /// Marks both cards matched and either flips the second card face-up (intermediate /// match) or triggers the game-over flow (final pair). + /// + /// Mutations are applied to a local copy and assigned to `model` once so observers + /// see a single snapshot covering both newly-matched cards and the bumped pair count. func handleMatch( index: Int, pendingIndex: Int ) { - model.cards[index].isMatched = true - model.cards[pendingIndex].isMatched = true - model.matches += 1 + var newModel = model + newModel.cards[index].isMatched = true + newModel.cards[pendingIndex].isMatched = true + newModel.matches += 1 + model = newModel // swiftformat:disable redundantSelf logGame .info( @@ -255,13 +260,19 @@ private extension GameViewModel { } /// Flips both cards face-down once the delayed timer fires. + /// + /// Both flag flips are applied to a local copy and assigned to `model` once so + /// observers don't briefly see a half-updated state with one card face-up and one + /// card face-down. func flipBackCards( index1: Int, index2: Int ) { logGame.debug("Flip-back timer fired — returning cards \(index1) and \(index2) face-down") - model.cards[index1].isFlipped = false - model.cards[index2].isFlipped = false + var newModel = model + newModel.cards[index1].isFlipped = false + newModel.cards[index2].isFlipped = false + model = newModel onFlipCard?(index1, false) onFlipCard?(index2, false) } diff --git a/SwiftIntroTests/Views/SingleCellTypeCollectionViewTests.swift b/SwiftIntroTests/Views/SingleCellTypeCollectionViewTests.swift index 5e93521..671f1f1 100644 --- a/SwiftIntroTests/Views/SingleCellTypeCollectionViewTests.swift +++ b/SwiftIntroTests/Views/SingleCellTypeCollectionViewTests.swift @@ -12,13 +12,6 @@ import XCTest final class SingleCellTypeCollectionViewTests: XCTestCase { // MARK: - make - func apa() { - let string = "A cool string" - if string != "A cool string" { - print("Not A cool string") - } - } - func test_make_returnsInstanceOfExpectedType() { // Act let cv = SingleCellTypeCollectionView.make()