Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/LaunchingView/Private/BlockingLaunchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ struct BlockingLaunchView: View {
}
}

if linkURL != nil {
if let linkURL {
Button {
onButtonTapped(linkURL)
} label: {
Expand Down
63 changes: 43 additions & 20 deletions Tests/LaunchingViewTests/LaunchingViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ struct LaunchingViewTests {
}

@Test
func fetchWhileFetchingMarksPendingRefresh() {
func fetchWhileFetchingRefreshesAgainAfterCurrentStatusFinishes() async {
let url = URL(string: "https://example.com/force")!
let forceStatus = AppUpdateStatus.forcedUpdateRequired(
UpdateAlert(
Expand All @@ -191,33 +191,56 @@ struct LaunchingViewTests {
alertDoneLinkURL: url
)
)
var state = Launching.State(isFetching: true)
let reducer = Launching()

_ = reducer.reduce(into: &state, action: .fetchAppUpdateStatus)

#expect(state.hasPendingFetch)
let store = TestStore(
initialState: Launching.State(isFetching: true),
reducer: Launching()
)
store.dependencies.launchingAlertDefaultText = LaunchingAlertDefaultText(
forceUpdate: .init(done: "Update")
)
store.dependencies.launchingService = StubLaunchingService(status: .valid)

withDependencies {
$0.launchingAlertDefaultText = LaunchingAlertDefaultText(
forceUpdate: .init(done: "Update")
)
} operation: {
_ = reducer.reduce(into: &state, action: .setAppUpdateStatus(forceStatus))
await store.send(.fetchAppUpdateStatus) {
$0.hasPendingFetch = true
}

#expect(!state.isFetching)
#expect(!state.hasPendingFetch)
#expect(state.appUpdateStatus == forceStatus)
#expect(state.displayContentView == false)
#expect(
state.blockingAlert == Launching.State.BlockingAlert(
await store.send(.setAppUpdateStatus(forceStatus)) {
$0.isFetching = false
$0.hasPendingFetch = false
$0.appUpdateStatus = forceStatus
$0.displayContentView = false
$0.blockingAlert = Launching.State.BlockingAlert(
title: "Force update",
message: "Please update now",
buttonTitle: "Update",
linkURL: url
)
)
}

await store.receive(.fetchAppUpdateStatus) {
$0.isFetching = true
}

await store.receive(.setAppUpdateStatus(.valid)) {
$0.isFetching = false
$0.appUpdateStatus = .valid
$0.displayContentView = true
$0.blockingAlert = nil
$0.optionalUpdateAlert = nil
$0.noticeAlert = nil
}
}
}

private final class StubLaunchingService: LaunchingInteractable {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] StubLaunchingService는 참조 의미 구조가 필요 없고 내부 상태가 불변이므로 final class 대신 struct로 선언하는 것이 더 관용적입니다. struct를 사용하면 컴파일러가 제공하는 memberwise initializer를 활용할 수 있어 명시적인 init 메서드(238-240라인)를 생략할 수 있고, 값 타입으로서 동시성 환경에서도 보다 안전합니다.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검토했습니다. 이 제안은 현재 코드에는 적용할 수 없습니다. LaunchingServiceLaunchingInteractable 프로토콜이 AnyObject를 상속하고 있어 class-only protocol입니다. 따라서 struct StubLaunchingService는 프로토콜을 채택할 수 없고, 테스트 stub은 final class로 유지해야 합니다. 내부 상태가 불변인 점은 맞지만 이 경우에는 프로토콜 제약이 우선입니다.

private let status: AppUpdateStatus

init(status: AppUpdateStatus) {
self.status = status
}

func fetchAppUpdateStatus() async throws -> AppUpdateStatus {
status
}
}

Expand Down
Loading