Phase 2c: Round List TDD Session
Date: 2025-11-23 Duration: ~6 hours PR: #291 (MERGED) Approach: Test-Driven Development (RED-GREEN-REFACTOR)
Quick Summary
Implemented iOS Round List feature with tab-based filtering using strict TDD methodology. Session yielded three critical technical discoveries that will impact all future iOS KMP development.
Key Achievements:
- ✅ 13 comprehensive tests (100% passing)
- ✅ RoundListViewModel with reactive Combine architecture
- ✅ RoundListView with SwiftUI tabs
- ✅ Fixed iOS test infrastructure issues
- ✅ 3 reusable KMP patterns documented
Critical Discoveries
1. KMP Enum Comparison Pattern ⚠️ CRITICAL
Problem: Kotlin enums are reference types in Swift, not value types.
// ❌ FAILS - Compares object identity
let filtered = rounds.filter { $0.status == .inProgress }
// ✅ WORKS - Compares enum names
let filtered = rounds.filter { $0.status.name == RoundStatus.inProgress.name }Why: Kotlin enum class maps to Swift as reference type classes. Each case is a singleton instance. The == operator compares references, not values. Use .name property for value comparison.
Impact: Affects ALL enum comparisons across iOS KMP integration.
Where to Use:
- Filtering collections by enum
- Conditionals checking enum values
- Switch statements on KMP enums
- Any equality check between KMP enums
2. Framework Search Paths for Test Targets ⚠️ CRITICAL
Problem: Test targets with CocoaPods + custom frameworks need explicit path inheritance.
<!-- ❌ BROKEN - Test can't find CocoaPods frameworks -->
"FRAMEWORK_SEARCH_PATHS[arch=*]" = (
"$(SRCROOT)/../../shared/presentation/...",
);
<!-- ✅ FIXED - Inherits CocoaPods paths -->
"FRAMEWORK_SEARCH_PATHS[arch=*]" = (
"$(inherited)", // MUST BE FIRST
"$(SRCROOT)/../../shared/presentation/...",
);Why: Architecture-specific settings ([arch=*]) override base settings completely. Without $(inherited), test target ignores CocoaPods xcconfig framework paths.
Impact: Without this, test targets can’t use ANY CocoaPods dependencies.
File: iosApp/ArcheryApprentice/ArcheryApprentice.xcodeproj/project.pbxproj
3. User Script Sandboxing for Test Targets
Problem: Xcode 15+ sandboxes build scripts, breaking CocoaPods resource copying.
<!-- Test target only -->
ENABLE_USER_SCRIPT_SANDBOXING = NO;Alternative: Remove pods with resource bundles from test target if not needed.
# Podfile
target 'ArcheryApprenticeTests' do
inherit! :search_paths
pod 'FirebaseAuth' # No resources ✅
pod 'FirebaseCore' # No resources ✅
# pod 'GoogleSignIn' # HAS resources ❌
endImplementation Details
RoundListViewModel (233 lines)
Architecture:
- Repository protocol for testing
- Reactive Combine-based filtering
- Tab-based status filters
- Loading and error states
- Real-time count calculations
Key Pattern - Reactive Filtering:
private func setupObservers() {
$rounds
.combineLatest($selectedTabIndex)
.sink { [weak self] rounds, tabIndex in
self?.updateFilteredRounds()
}
.store(in: &cancellables)
}Critical Code - Enum Comparison:
// File: RoundListViewModel.swift:131
return rounds.filter { $0.status.name == status.name }.count
// File: RoundListViewModel.swift:160
filteredRounds = rounds.filter { $0.status.name == status.name }RoundListView (390 lines)
UI Components:
- Tab bar with 5 filters (All, In Progress, Completed, Paused, Planned)
- Round cards with details (name, date, distance, score, status)
- Loading state (centered spinner)
- Error state (message + retry button)
- Empty states (context-aware messages)
- Pull-to-refresh
State Management:
if viewModel.isLoading {
LoadingView()
} else if let error = viewModel.errorMessage {
ErrorView(message: error, onRetry: { Task { await viewModel.refresh() } })
} else if viewModel.filteredRounds.isEmpty {
EmptyStateView(selectedTab: viewModel.selectedTabIndex)
} else {
RoundListContent(rounds: viewModel.filteredRounds)
}Test Suite (370 lines, 13 tests)
Test Categories:
- Initialization (3 tests) - Clean starting state
- Load Rounds (3 tests) - Repository integration
- Filter by Status (3 tests) - Tab filtering logic
- Tab Counts (1 test) - Real-time calculations
- Empty States (2 tests) - No data handling
- Error Handling (2 tests) - Error management
- Refresh (1 test) - Pull-to-refresh
Mock Repository Pattern:
class MockRoundRepository: RoundRepositoryProtocol {
var roundsToReturn: [Round] = []
var shouldFail = false
var errorToThrow: Error?
var simulateDelay = false
func getAllRounds() async throws -> [Round] {
if simulateDelay {
try? await Task.sleep(nanoseconds: 500_000_000)
}
if shouldFail {
throw errorToThrow ?? NSError(domain: "MockError", code: 500)
}
return roundsToReturn
}
}TDD Journey
Phase 1: RED - Write Failing Tests
Created 13 tests defining expected behavior:
- Initialization tests
- Repository integration tests
- Filtering logic tests
- Count calculation tests
- Empty state tests
- Error handling tests
- Refresh tests
Phase 2: GREEN - Minimal Implementation
Implemented RoundListViewModel to pass tests.
Discovered: KMP enum comparison issue causing filter tests to fail.
Fixed: Changed == to .name comparison.
Phase 3: REFACTOR - Infrastructure Issues
Problem: Tests wouldn’t compile - linker couldn’t find Firebase frameworks.
Investigation:
- Checked CocoaPods xcconfig - paths present ✅
- Compared main vs test target settings
- Found missing
$(inherited)in test target
Fixed: Added $(inherited) to FRAMEWORK_SEARCH_PATHS[arch=*]
Additional Issues:
- User script sandboxing blocking CocoaPods
- GoogleSignIn resource bundles conflicting
Phase 4: GREEN (Post-Fix) - All Tests Passing
13/13 tests passing (0.611 seconds total)
Phase 5: UI Implementation
Built SwiftUI views using tested ViewModel.
Phase 6: Copilot Review
Addressed 6 review comments:
- Removed debug statements
- Fixed SF Symbol names
- Added code coverage config
- Improved error handling
- Enhanced build validation
Files Changed
Added:
RoundListViewModel.swift(233 lines)RoundListView.swift(390 lines)RoundListViewModelTests.swift(370 lines)ArcheryApprentice.xctestplan(24 lines)
Modified:
project.pbxproj(+109/-56 lines)Podfile(+34/-11 lines)
Total: 7 files, +1,184 lines, -67 lines
Lessons Learned
1. TDD Catches Issues Early
Test-first approach discovered:
- KMP enum comparison issue (before UI)
- Framework search paths issue (during test setup)
- Mock repository pattern enabled fast iteration
Result: No UI rework needed - issues found and fixed during test phase.
2. KMP Type Bridging Needs Documentation
Kotlin → Swift type mappings are non-obvious:
- Enums become reference types
IntbecomesInt32LongbecomesInt64List<T>becomes[KotlinT]
Action: Added patterns to CLAUDE.md for future reference.
3. Test Target Configuration Requires Care
Test targets don’t automatically inherit main target settings:
- Framework search paths need explicit
$(inherited) - User script sandboxing may need disabling
- Pod dependencies may differ from main target
Best Practice: Configure test target immediately when adding pods/frameworks.
Next Steps
Immediate:
- Wire RoundListView into app navigation
- Implement Round Detail View (tap on round card)
- Connect to real repository (replace mock)
Short-Term:
- Phase 2d: Settings Screen
- Phase 2e: Active Scoring Screen
- Phase 2f: Round Details with Statistics
Long-Term:
- Phase 3: Equipment Management (95% shared code)
- Phase 5: Authentication (complete deferred work)
References
Documentation:
- Full session details:
docs/IOS_PHASE_2C_ROUND_LIST_SESSION.md(main repo) - KMP patterns:
docs/CLAUDE.md- iOS KMP Development Patterns section (main repo) - Phase 2 roadmap: roadmap
- Testing strategy: testing-strategy
Code:
- ViewModel:
iosApp/ArcheryApprentice/ArcheryApprentice/ViewModels/RoundListViewModel.swift - View:
iosApp/ArcheryApprentice/ArcheryApprentice/Views/RoundListView.swift - Tests:
iosApp/ArcheryApprentice/ArcheryApprenticeTests/RoundListViewModelTests.swift - Project config:
iosApp/ArcheryApprentice/ArcheryApprentice.xcodeproj/project.pbxproj
Pull Request: #291 - feat(ios): Phase 2c - Round List with TDD
Key Takeaways
✅ TDD works excellently for iOS KMP development
- Catches type bridging issues early
- Forces thinking about architecture
- Provides safety net for refactoring
✅ KMP enum comparison is critical
- Must use
.nameproperty - Affects all enum filtering/comparison
- Pattern now documented
✅ Test infrastructure needs explicit configuration
$(inherited)required for framework paths- User script sandboxing may need disabling
- Test targets are not automatic copies of main target
✅ Copilot review adds production quality
- Caught debug code
- Improved error handling
- Added code coverage tracking
Session completed: 2025-11-23 PR #291: MERGED Phase 2c: COMPLETE ✅