Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle connection lost error on the DailyChallengeView #179

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
45 changes: 34 additions & 11 deletions Sources/DailyChallengeFeature/DailyChallengeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,22 @@ public struct DailyChallengeReducer: Reducer {
public var dailyChallenges: [FetchTodaysDailyChallengeResponse]
@PresentationState public var destination: Destination.State?
public var gameModeIsLoading: GameMode?
public var gameBlockingError: String?
public var inProgressDailyChallengeUnlimited: InProgressGame?
public var userNotificationSettings: UserNotificationClient.Notification.Settings?

public init(
dailyChallenges: [FetchTodaysDailyChallengeResponse] = [],
destination: Destination.State? = nil,
gameModeIsLoading: GameMode? = nil,
gameBlockingError: String? = nil,
inProgressDailyChallengeUnlimited: InProgressGame? = nil,
userNotificationSettings: UserNotificationClient.Notification.Settings? = nil
) {
self.dailyChallenges = dailyChallenges
self.destination = destination
self.gameModeIsLoading = gameModeIsLoading
self.gameBlockingError = gameBlockingError
self.inProgressDailyChallengeUnlimited = inProgressDailyChallengeUnlimited
self.userNotificationSettings = userNotificationSettings
}
Expand Down Expand Up @@ -100,7 +103,10 @@ public struct DailyChallengeReducer: Reducer {
case .destination:
return .none

case .fetchTodaysDailyChallengeResponse(.failure):
case let .fetchTodaysDailyChallengeResponse(.failure(error)):
if let apiError = error as? LocalizedError {
state.gameBlockingError = apiError.failureReason
}
return .none

case let .fetchTodaysDailyChallengeResponse(.success(response)):
Expand All @@ -110,8 +116,12 @@ public struct DailyChallengeReducer: Reducer {
case let .gameButtonTapped(gameMode):
guard
let challenge = state.dailyChallenges
.first(where: { $0.dailyChallenge.gameMode == gameMode })
else { return .none }
.first(where: { $0.dailyChallenge.gameMode == gameMode }),
state.gameBlockingError == nil
else {
state.alert = .couldntStartGame(message: state.gameBlockingError)
return .none
}

let isPlayable: Bool
switch challenge.dailyChallenge.gameMode {
Expand Down Expand Up @@ -227,7 +237,15 @@ extension AlertState where Action == DailyChallengeReducer.Destination.Action.Al
)
}
}


static func couldntStartGame(message: String?) -> Self {
Self(
title: .init("Couldn’t start game"),
message: .init(message ?? ""),
dismissButton: .default(.init("OK"), action: .send(.dismissAlert))
)
}

static func couldNotFetchDaily(nextStartsAt: Date) -> Self {
Self {
TextState("Couldn’t start today’s daily")
Expand Down Expand Up @@ -264,7 +282,7 @@ public struct DailyChallengeView: View {
case played(rank: Int, outOf: Int)
case playable
case resume(currentScore: Int)
case unplayable
case unplayable(reason: String)
}

init(state: DailyChallengeReducer.State) {
Expand All @@ -274,11 +292,13 @@ public struct DailyChallengeView: View {
self.numberOfPlayers = state.dailyChallenges.numberOfPlayers
self.timedState = .init(
fetchedResponse: state.dailyChallenges.timed,
inProgressGame: nil
inProgressGame: nil,
error: state.gameBlockingError
)
self.unlimitedState = .init(
fetchedResponse: state.dailyChallenges.unlimited,
inProgressGame: state.inProgressDailyChallengeUnlimited
inProgressGame: state.inProgressDailyChallengeUnlimited,
error: state.gameBlockingError
)
}
}
Expand Down Expand Up @@ -409,7 +429,8 @@ public struct DailyChallengeView: View {
extension DailyChallengeView.ViewState.ButtonState {
init(
fetchedResponse: FetchTodaysDailyChallengeResponse?,
inProgressGame: InProgressGame?
inProgressGame: InProgressGame?,
error: String? = nil
) {
if let rank = fetchedResponse?.yourResult.rank,
let outOf = fetchedResponse?.yourResult.outOf
Expand All @@ -418,7 +439,9 @@ extension DailyChallengeView.ViewState.ButtonState {
} else if let currentScore = inProgressGame?.currentScore {
self = .resume(currentScore: currentScore)
} else if fetchedResponse?.yourResult.started == .some(true) {
self = .unplayable
self = .unplayable(reason: "Played")
} else if let error {
self = .unplayable(reason: error)
} else {
self = .playable
}
Expand All @@ -432,8 +455,8 @@ extension DailyChallengeView.ViewState.ButtonState {
return nil
case .playable:
return nil
case .unplayable:
return Text("Played")
case let .unplayable(reason):
return Text(reason)
}
}

Expand Down
31 changes: 31 additions & 0 deletions Sources/SharedModels/API/ApiError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ public struct ApiError: Codable, Error, Equatable, LocalizedError {
public let file: String
public let line: UInt
public let message: String
public var reason: FailureReason?

public init(
error: Error,
Expand All @@ -17,9 +18,39 @@ public struct ApiError: Codable, Error, Equatable, LocalizedError {
self.file = String(describing: file)
self.line = line
self.message = error.localizedDescription // TODO: separate user facing from debug facing messages?
if NSURLErrorConnectionFailureCodes.contains((error as NSError).code) {
self.reason = .offline
}
}

public var errorDescription: String? {
self.message
}

public var failureReason: String? {
self.reason?.description
}

public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.message == rhs.message && lhs.errorDump == rhs.errorDump
}
}

private let NSURLErrorConnectionFailureCodes: [Int] = [
NSURLErrorCannotFindHost, /// Error Code: ` -1003`
NSURLErrorCannotConnectToHost, /// Error Code: ` -1004`
NSURLErrorNetworkConnectionLost, /// Error Code: ` -1005`
NSURLErrorNotConnectedToInternet, /// Error Code: ` -1009`
NSURLErrorSecureConnectionFailed /// Error Code: ` -1200`
]

public enum FailureReason: CustomStringConvertible, Equatable, Codable {
case offline

public var description: String {
switch self {
case .offline:
return "Connection unavailable"
}
}
}
47 changes: 47 additions & 0 deletions Tests/DailyChallengeFeatureTests/DailyChallengeFeatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,53 @@ class DailyChallengeFeatureTests: XCTestCase {
)
}
}

func testFetchTodaysDailyChallengeThatReturnsAnError() async {
struct SomeError: LocalizedError {
let message: String?

var failureReason: String? { message }
}

let store = TestStore(
initialState: DailyChallengeReducer.State(),
reducer: DailyChallengeReducer()
)
store.dependencies.mainRunLoop = .immediate
store.dependencies.userNotifications.getNotificationSettings = {
.init(authorizationStatus: .authorized)
}
let connectionLostError = NSError(domain: "Network", code: -1005)
store.dependencies.apiClient.override(
route: .dailyChallenge(.today(language: .en)),
withResponse: { throw connectionLostError }
)

await store.send(.task)
await store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .authorized))) {
$0.userNotificationSettings = .init(authorizationStatus: .authorized)
}
await store.receive(.fetchTodaysDailyChallengeResponse(.failure(ApiError(error: connectionLostError)))) {
$0.gameBlockingError = "Connection unavailable"
}
}

func testTapGameThatIsBlockedDueToANetworkError() async {
var state = DailyChallengeReducer.State(dailyChallenges: [.notStarted])
let message = "Connection unavailable"
state.gameBlockingError = message

let store = TestStore(
initialState: state,
reducer: DailyChallengeReducer()
)
store.exhaustivity = .off

await store.send(.gameButtonTapped(.unlimited)) {
$0.alert = .couldntStartGame(message: message)
}
await store.finish()
}

func testTapGameThatWasNotStarted() async {
var inProgressGame = InProgressGame.mock
Expand Down
Loading