Implement full MVVM stack for two new community features: Clubs: - Persistent runner groups with type, privacy, and member management - Club discovery, creation, join/leave, and invite workflows - Member roles (Owner, Admin, Member) and capacity limits Challenges: - Time-bound competitive goals with progress tracking and leaderboards - Challenge types: distance, time, frequency, elevation, calories, streak - Progress submission, participation status, and ranking Files: - Models: Club.swift, Challenge.swift - Services: ClubService.swift, ChallengeService.swift - ViewModels: ClubViewModel.swift, ChallengeViewModel.swift - Views: ClubsView.swift, ClubDetailView.swift, ChallengesView.swift, ChallengeDetailView.swift - Tests: ClubServiceTests.swift, ChallengeServiceTests.swift - Updated README.md with new feature documentation
426 lines
15 KiB
Swift
426 lines
15 KiB
Swift
import XCTest
|
|
import SwiftUI
|
|
@testable import Lendair
|
|
|
|
// MARK: - Mock Challenge Service
|
|
|
|
final class MockChallengeService: ChallengeServiceProtocol {
|
|
var challenges: [Challenge] = []
|
|
var selectedChallenge: (challenge: Challenge, participants: [ChallengeParticipant])?
|
|
var joinCalledIds: [String] = []
|
|
var leaveCalledIds: [String] = []
|
|
var createCalled = false
|
|
var leaderboard: [LeaderboardEntry] = []
|
|
var listCallCount = 0
|
|
var listError: Error?
|
|
|
|
func listChallenges(filter: ChallengeFilter = ChallengeFilter()) async throws -> [Challenge] {
|
|
listCallCount += 1
|
|
if let error = listError { throw error }
|
|
return challenges
|
|
}
|
|
|
|
func getChallenge(id: String) async throws -> (challenge: Challenge, participants: [ChallengeParticipant]) {
|
|
if let selected = selectedChallenge { return selected }
|
|
throw ChallengeError.notFound
|
|
}
|
|
|
|
func createChallenge(request: CreateChallengeRequest) async throws -> Challenge {
|
|
createCalled = true
|
|
return Challenge(
|
|
id: "new-1",
|
|
title: request.title,
|
|
description: request.description,
|
|
challengeType: request.challengeType,
|
|
status: .active,
|
|
startDate: request.startDate,
|
|
endDate: request.endDate,
|
|
targetMetric: request.targetMetric,
|
|
targetValue: request.targetValue,
|
|
targetUnit: request.targetMetric.unit,
|
|
participantCount: 1,
|
|
rules: request.rules,
|
|
imageUrl: nil,
|
|
createdBy: "current-user",
|
|
createdByName: "Current User",
|
|
clubId: request.clubId,
|
|
participationStatus: .participating,
|
|
userProgress: 0,
|
|
createdAt: Date()
|
|
)
|
|
}
|
|
|
|
func updateChallenge(id: String, request: UpdateChallengeRequest) async throws -> Challenge {
|
|
throw ChallengeError.notFound
|
|
}
|
|
|
|
func joinChallenge(id: String) async throws {
|
|
joinCalledIds.append(id)
|
|
}
|
|
|
|
func leaveChallenge(id: String) async throws {
|
|
leaveCalledIds.append(id)
|
|
}
|
|
|
|
func getLeaderboard(challengeId: String) async throws -> [LeaderboardEntry] {
|
|
return leaderboard
|
|
}
|
|
|
|
func submitProgress(challengeId: String, progress: ProgressSubmission) async throws -> (progress: Double, percentage: Double) {
|
|
return (progress.value, min((progress.value / 100) * 100, 100))
|
|
}
|
|
}
|
|
|
|
// MARK: - Helper: Sample Challenges
|
|
|
|
extension Challenge {
|
|
static func sample(
|
|
id: String = "test-1",
|
|
title: String = "Test Challenge",
|
|
challengeType: ChallengeType = .distance,
|
|
status: ChallengeStatus = .active,
|
|
participationStatus: ParticipationStatus = .participating,
|
|
userProgress: Double = 0,
|
|
targetValue: Double = 100,
|
|
startDate: Date = Date().addingTimeInterval(-7 * 24 * 3600),
|
|
endDate: Date = Date().addingTimeInterval(23 * 24 * 3600)
|
|
) -> Challenge {
|
|
Challenge(
|
|
id: id,
|
|
title: title,
|
|
description: "Test description",
|
|
challengeType: challengeType,
|
|
status: status,
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
targetMetric: .distance,
|
|
targetValue: targetValue,
|
|
targetUnit: "km",
|
|
participantCount: 10,
|
|
rules: nil,
|
|
imageUrl: nil,
|
|
createdBy: "user-1",
|
|
createdByName: "Test User",
|
|
clubId: nil,
|
|
participationStatus: participationStatus,
|
|
userProgress: userProgress,
|
|
createdAt: Date()
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - ChallengeServiceTests
|
|
|
|
final class ChallengeServiceTests: XCTestCase {
|
|
// MARK: - Fetch Challenges
|
|
|
|
@MainActor
|
|
func testFetchChallengesLoadsData() async {
|
|
let mock = MockChallengeService()
|
|
mock.challenges = [.sample(id: "1"), .sample(id: "2")]
|
|
|
|
let viewModel = ChallengeViewModel(service: mock)
|
|
await viewModel.fetchChallenges()
|
|
|
|
XCTAssertEqual(viewModel.challenges.count, 2)
|
|
XCTAssertFalse(viewModel.isLoading)
|
|
XCTAssertEqual(mock.listCallCount, 1)
|
|
}
|
|
|
|
@MainActor
|
|
func testFetchChallengesHandlesError() async {
|
|
let mock = MockChallengeService()
|
|
mock.listError = ChallengeError.unauthorized
|
|
|
|
let viewModel = ChallengeViewModel(service: mock)
|
|
await viewModel.fetchChallenges()
|
|
|
|
XCTAssertTrue(viewModel.challenges.isEmpty)
|
|
XCTAssertFalse(viewModel.isLoading)
|
|
XCTAssertEqual(viewModel.error, .unauthorized)
|
|
}
|
|
|
|
// MARK: - Challenge Type Display
|
|
|
|
func testChallengeTypeDisplayNames() {
|
|
XCTAssertEqual(ChallengeType.distance.displayName, "Distance")
|
|
XCTAssertEqual(ChallengeType.time.displayName, "Time")
|
|
XCTAssertEqual(ChallengeType.frequency.displayName, "Frequency")
|
|
XCTAssertEqual(ChallengeType.elevation.displayName, "Elevation")
|
|
XCTAssertEqual(ChallengeType.calories.displayName, "Calories")
|
|
XCTAssertEqual(ChallengeType.streak.displayName, "Streak")
|
|
}
|
|
|
|
func testChallengeTypeIcons() {
|
|
XCTAssertEqual(ChallengeType.distance.icon, "arrow.right.arrow.left")
|
|
XCTAssertEqual(ChallengeType.time.icon, "stopwatch.fill")
|
|
XCTAssertEqual(ChallengeType.frequency.icon, "repeat")
|
|
XCTAssertEqual(ChallengeType.elevation.icon, "mountain.2.fill")
|
|
XCTAssertEqual(ChallengeType.calories.icon, "flame.fill")
|
|
XCTAssertEqual(ChallengeType.streak.icon, "calendar.badge.clock")
|
|
}
|
|
|
|
func testChallengeMetricUnits() {
|
|
XCTAssertEqual(ChallengeMetric.distance.unit, "km")
|
|
XCTAssertEqual(ChallengeMetric.time.unit, "min")
|
|
XCTAssertEqual(ChallengeMetric.frequency.unit, "sessions")
|
|
XCTAssertEqual(ChallengeMetric.elevation.unit, "m")
|
|
XCTAssertEqual(ChallengeMetric.calories.unit, "kcal")
|
|
}
|
|
|
|
// MARK: - Challenge Time States
|
|
|
|
func testChallengeIsUpcoming() {
|
|
let future = Challenge.sample(
|
|
id: "1",
|
|
startDate: Date().addingTimeInterval(7 * 24 * 3600),
|
|
endDate: Date().addingTimeInterval(37 * 24 * 3600)
|
|
)
|
|
XCTAssertTrue(future.isUpcoming)
|
|
}
|
|
|
|
func testChallengeIsActive() {
|
|
let active = Challenge.sample(id: "1")
|
|
XCTAssertTrue(active.isActive)
|
|
}
|
|
|
|
func testChallengeIsCompleted() {
|
|
let past = Challenge.sample(
|
|
id: "1",
|
|
startDate: Date().addingTimeInterval(-30 * 24 * 3600),
|
|
endDate: Date().addingTimeInterval(-7 * 24 * 3600)
|
|
)
|
|
XCTAssertTrue(past.isCompleted)
|
|
}
|
|
|
|
// MARK: - Progress Percentage
|
|
|
|
func testProgressPercentage() {
|
|
var challenge = Challenge.sample(id: "1", userProgress: 50, targetValue: 100)
|
|
XCTAssertEqual(challenge.progressPercentage, 50)
|
|
}
|
|
|
|
func testProgressPercentageOverTarget() {
|
|
var challenge = Challenge.sample(id: "1", userProgress: 120, targetValue: 100)
|
|
XCTAssertEqual(challenge.progressPercentage, 100)
|
|
}
|
|
|
|
func testProgressPercentageNoProgress() {
|
|
var challenge = Challenge.sample(id: "1", userProgress: 0, targetValue: 100)
|
|
XCTAssertEqual(challenge.progressPercentage, 0)
|
|
}
|
|
|
|
func testProgressPercentageNilProgress() {
|
|
var challenge = Challenge.sample(id: "1", userProgress: nil, targetValue: 100)
|
|
XCTAssertEqual(challenge.progressPercentage, 0)
|
|
}
|
|
|
|
// MARK: - Days Remaining
|
|
|
|
func testDaysRemaining() {
|
|
let challenge = Challenge.sample(
|
|
id: "1",
|
|
endDate: Date().addingTimeInterval(5 * 24 * 3600)
|
|
)
|
|
XCTAssertGreaterThan(challenge.daysRemaining, 0)
|
|
}
|
|
|
|
// MARK: - Computed Filters
|
|
|
|
@MainActor
|
|
func testActiveChallengesFiltersCorrectly() async {
|
|
let mock = MockChallengeService()
|
|
mock.challenges = [
|
|
Challenge.sample(id: "1", startDate: Date().addingTimeInterval(-1 * 86400), endDate: Date().addingTimeInterval(7 * 86400)),
|
|
Challenge.sample(id: "2", startDate: Date().addingTimeInterval(7 * 86400), endDate: Date().addingTimeInterval(37 * 86400)),
|
|
Challenge.sample(id: "3", startDate: Date().addingTimeInterval(-10 * 86400), endDate: Date().addingTimeInterval(-1 * 86400)),
|
|
]
|
|
|
|
let viewModel = ChallengeViewModel(service: mock)
|
|
await viewModel.fetchChallenges()
|
|
|
|
XCTAssertEqual(viewModel.activeChallenges.count, 1)
|
|
XCTAssertEqual(viewModel.activeChallenges.first?.id, "1")
|
|
}
|
|
|
|
@MainActor
|
|
func testUpcomingChallengesFiltersCorrectly() async {
|
|
let mock = MockChallengeService()
|
|
mock.challenges = [
|
|
Challenge.sample(id: "1", startDate: Date().addingTimeInterval(-1 * 86400), endDate: Date().addingTimeInterval(7 * 86400)),
|
|
Challenge.sample(id: "2", startDate: Date().addingTimeInterval(7 * 86400), endDate: Date().addingTimeInterval(37 * 86400)),
|
|
]
|
|
|
|
let viewModel = ChallengeViewModel(service: mock)
|
|
await viewModel.fetchChallenges()
|
|
|
|
XCTAssertEqual(viewModel.upcomingChallenges.count, 1)
|
|
XCTAssertEqual(viewModel.upcomingChallenges.first?.id, "2")
|
|
}
|
|
|
|
@MainActor
|
|
func testCompletedChallengesFiltersCorrectly() async {
|
|
let mock = MockChallengeService()
|
|
mock.challenges = [
|
|
Challenge.sample(id: "1", startDate: Date().addingTimeInterval(-10 * 86400), endDate: Date().addingTimeInterval(-1 * 86400)),
|
|
Challenge.sample(id: "2", startDate: Date().addingTimeInterval(-1 * 86400), endDate: Date().addingTimeInterval(7 * 86400)),
|
|
]
|
|
|
|
let viewModel = ChallengeViewModel(service: mock)
|
|
await viewModel.fetchChallenges()
|
|
|
|
XCTAssertEqual(viewModel.completedChallenges.count, 1)
|
|
XCTAssertEqual(viewModel.completedChallenges.first?.id, "1")
|
|
}
|
|
|
|
@MainActor
|
|
func testUserChallengesFiltersParticipating() async {
|
|
let mock = MockChallengeService()
|
|
mock.challenges = [
|
|
Challenge.sample(id: "1", participationStatus: .participating),
|
|
Challenge.sample(id: "2", participationStatus: .notParticipating),
|
|
Challenge.sample(id: "3", participationStatus: .participating),
|
|
]
|
|
|
|
let viewModel = ChallengeViewModel(service: mock)
|
|
await viewModel.fetchChallenges()
|
|
|
|
XCTAssertEqual(viewModel.userChallenges.count, 2)
|
|
XCTAssertTrue(viewModel.userChallenges.allSatisfy { $0.participationStatus == .participating })
|
|
}
|
|
|
|
// MARK: - Join and Leave
|
|
|
|
@MainActor
|
|
func testJoinChallengeUpdatesLocalState() async {
|
|
let mock = MockChallengeService()
|
|
let challenge = Challenge.sample(id: "1", participationStatus: .notParticipating)
|
|
mock.challenges = [challenge]
|
|
|
|
let viewModel = ChallengeViewModel(service: mock)
|
|
viewModel.challenges = [challenge]
|
|
|
|
await viewModel.joinChallenge(id: "1")
|
|
|
|
XCTAssertEqual(viewModel.challenges.first?.participationStatus, .participating)
|
|
XCTAssertEqual(viewModel.challenges.first?.participantCount, 11)
|
|
XCTAssertEqual(mock.joinCalledIds, ["1"])
|
|
}
|
|
|
|
@MainActor
|
|
func testLeaveChallengeUpdatesLocalState() async {
|
|
let mock = MockChallengeService()
|
|
let challenge = Challenge.sample(id: "1", participationStatus: .participating)
|
|
mock.challenges = [challenge]
|
|
|
|
let viewModel = ChallengeViewModel(service: mock)
|
|
viewModel.challenges = [challenge]
|
|
|
|
await viewModel.leaveChallenge(id: "1")
|
|
|
|
XCTAssertEqual(viewModel.challenges.first?.participationStatus, .notParticipating)
|
|
XCTAssertEqual(viewModel.challenges.first?.participantCount, 9)
|
|
XCTAssertEqual(mock.leaveCalledIds, ["1"])
|
|
}
|
|
|
|
// MARK: - Challenge Equality
|
|
|
|
func testChallengeEquality() {
|
|
let a = Challenge.sample(id: "1", participationStatus: .participating)
|
|
let b = Challenge.sample(id: "1", participationStatus: .participating)
|
|
let c = Challenge.sample(id: "1", participationStatus: .notParticipating)
|
|
|
|
XCTAssertEqual(a, b)
|
|
XCTAssertNotEqual(a, c)
|
|
}
|
|
|
|
// MARK: - Create Challenge
|
|
|
|
@MainActor
|
|
func testCreateChallengeAddsToList() async {
|
|
let mock = MockChallengeService()
|
|
let viewModel = ChallengeViewModel(service: mock)
|
|
|
|
let request = CreateChallengeRequest(
|
|
title: "New Challenge",
|
|
description: "A new challenge",
|
|
challengeType: .distance,
|
|
startDate: Date(),
|
|
endDate: Date().addingTimeInterval(30 * 24 * 3600),
|
|
targetMetric: .distance,
|
|
targetValue: 50,
|
|
rules: nil,
|
|
clubId: nil
|
|
)
|
|
|
|
let result = await viewModel.createChallenge(request: request)
|
|
|
|
XCTAssertNotNil(result)
|
|
XCTAssertEqual(viewModel.challenges.count, 1)
|
|
XCTAssertEqual(viewModel.challenges.first?.title, "New Challenge")
|
|
XCTAssertTrue(mock.createCalled)
|
|
}
|
|
|
|
// MARK: - Leaderboard
|
|
|
|
@MainActor
|
|
func testFetchLeaderboardLoadsData() async {
|
|
let mock = MockChallengeService()
|
|
mock.leaderboard = [
|
|
LeaderboardEntry(
|
|
id: "1", position: 1, participantId: "user1",
|
|
participantName: "Alice", participantAvatarUrl: nil,
|
|
progress: 100, progressPercentage: 100
|
|
),
|
|
LeaderboardEntry(
|
|
id: "2", position: 2, participantId: "user2",
|
|
participantName: "Bob", participantAvatarUrl: nil,
|
|
progress: 75, progressPercentage: 75
|
|
),
|
|
]
|
|
|
|
let viewModel = ChallengeViewModel(service: mock)
|
|
await viewModel.fetchLeaderboard(challengeId: "1")
|
|
|
|
XCTAssertEqual(viewModel.leaderboard.count, 2)
|
|
XCTAssertEqual(viewModel.leaderboard.first?.position, 1)
|
|
}
|
|
|
|
// MARK: - Submit Progress
|
|
|
|
@MainActor
|
|
func testSubmitProgressUpdatesChallenge() async {
|
|
let mock = MockChallengeService()
|
|
let challenge = Challenge.sample(id: "1", userProgress: 0)
|
|
mock.challenges = [challenge]
|
|
|
|
let viewModel = ChallengeViewModel(service: mock)
|
|
viewModel.challenges = [challenge]
|
|
|
|
let progress = ProgressSubmission(metric: .distance, value: 50, activityDate: Date())
|
|
await viewModel.submitProgress(challengeId: "1", progress: progress)
|
|
|
|
XCTAssertEqual(viewModel.challenges.first?.userProgress, 50)
|
|
}
|
|
|
|
// MARK: - Challenge Filter Defaults
|
|
|
|
func testChallengeFilterDefaults() {
|
|
let filter = ChallengeFilter()
|
|
XCTAssertEqual(filter.limit, 20)
|
|
XCTAssertEqual(filter.offset, 0)
|
|
XCTAssertNil(filter.challengeType)
|
|
XCTAssertNil(filter.status)
|
|
}
|
|
|
|
// MARK: - Challenge Status Cases
|
|
|
|
func testChallengeStatusCases() {
|
|
XCTAssertEqual(ChallengeStatus.allCases.count, 4)
|
|
XCTAssertEqual(ChallengeStatus.upcoming.rawValue, "upcoming")
|
|
XCTAssertEqual(ChallengeStatus.active.rawValue, "active")
|
|
XCTAssertEqual(ChallengeStatus.completed.rawValue, "completed")
|
|
XCTAssertEqual(ChallengeStatus.cancelled.rawValue, "cancelled")
|
|
}
|
|
}
|