From 48e3bd2bbe0460f08a25f70cc927b7968aa211f3 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:45:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20PushNotificationSettingsView=20?= =?UTF-8?q?TCA=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Main/MainView.swift | 2 +- .../Sources/Profile/ProfileView.swift | 2 +- .../Profile/ProfileViewCoordinator.swift | 12 +- .../PushNotificationSettingsFeature.swift | 205 ++++++++++++++++++ .../PushNotificationSettingsView.swift | 122 +++++------ .../PushNotificationSettingsViewModel.swift | 170 --------------- 6 files changed, 271 insertions(+), 242 deletions(-) create mode 100644 Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift delete mode 100644 Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsViewModel.swift diff --git a/Application/DevLogPresentation/Sources/Main/MainView.swift b/Application/DevLogPresentation/Sources/Main/MainView.swift index c4509d3a..bc9eb995 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -372,7 +372,7 @@ struct MainView: View { ) case .pushNotification: PushNotificationSettingsView( - viewModel: profileViewCoordinator.makePushNotificationSettingsViewModel() + store: profileViewCoordinator.makePushNotificationSettingsStore() ) case .account: AccountView(store: profileViewCoordinator.makeAccountStore()) diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift index c17f4e2a..617e85a5 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift @@ -170,7 +170,7 @@ struct ProfileView: View { ) ) case .pushNotification: - PushNotificationSettingsView(viewModel: coordinator.makePushNotificationSettingsViewModel()) + PushNotificationSettingsView(store: coordinator.makePushNotificationSettingsStore()) case .account: AccountView(store: coordinator.makeAccountStore()) } diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift b/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift index 034b25ce..dd8300c6 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift @@ -54,11 +54,13 @@ final class ProfileViewCoordinator { } } - func makePushNotificationSettingsViewModel() -> PushNotificationSettingsViewModel { - PushNotificationSettingsViewModel( - fetchPushSettingsUseCase: container.resolve(FetchPushSettingsUseCase.self), - updatePushSettingsUseCase: container.resolve(UpdatePushSettingsUseCase.self) - ) + func makePushNotificationSettingsStore() -> StoreOf { + Store(initialState: PushNotificationSettingsFeature.State()) { + PushNotificationSettingsFeature() + } withDependencies: { + $0.fetchPushSettingsUseCase = self.container.resolve(FetchPushSettingsUseCase.self) + $0.updatePushSettingsUseCase = self.container.resolve(UpdatePushSettingsUseCase.self) + } } func makeTodoDetailStore(todoId: String) -> StoreOf { diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift new file mode 100644 index 00000000..fa52bde0 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift @@ -0,0 +1,205 @@ +// +// PushNotificationSettingsFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/12/26. +// + +import ComposableArchitecture +import DevLogDomain +import Foundation +import SwiftUI + +@Reducer +struct PushNotificationSettingsFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + @Presents var timePicker: TimePickerState? + var pushNotificationEnable = false + var viewPushNotificationTime = Date() + var loading = LoadingFeature.State() + + var isLoading: Bool { + loading.isLoading + } + var pushNotificationHour: Int { + Calendar.current.component(.hour, from: viewPushNotificationTime) + } + var pushNotificationMinute: Int { + Calendar.current.component(.minute, from: viewPushNotificationTime) + } + } + + @ObservableState + struct TimePickerState: Equatable { + var time: Date + var height: CGFloat = .pi + } + + enum Action: BindableAction { + case alert(PresentationAction) + case binding(BindingAction) + case timePicker(PresentationAction) + case fetchSettings + case setAlert + case tapCustomTime + case selectPresetTime(Date) + case loading(LoadingFeature.Action) + + enum TimePicker: BindableAction, Equatable { + case binding(BindingAction) + case tapCloseButton + case tapDoneButton + } + } + + @Dependency(\.fetchPushSettingsUseCase) var fetchPushSettingsUseCase + @Dependency(\.updatePushSettingsUseCase) var updatePushSettingsUseCase + + var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } + BindingReducer() + Reduce { state, action in + switch action { + case .alert: + break + case .binding(\.pushNotificationEnable): + return updatePushNotificationSettingsEffect(settings: settings(from: state)) + case .binding(\.viewPushNotificationTime): + let time = state.viewPushNotificationTime + state.timePicker?.time = time + case .binding: + break + case .timePicker(.dismiss): + state.timePicker = nil + case .timePicker(.presented(.tapCloseButton)): + state.timePicker = nil + case .timePicker(.presented(.tapDoneButton)): + guard let time = state.timePicker?.time else { break } + state.timePicker = nil + state.viewPushNotificationTime = time + return updatePushNotificationSettingsEffect(settings: settings(from: state)) + case .timePicker: + break + case .fetchSettings: + return fetchPushNotificationSettingsEffect() + case .setAlert: + state.alert = alertState() + case .tapCustomTime: + state.timePicker = TimePickerState(time: state.viewPushNotificationTime) + case .selectPresetTime(let date): + state.viewPushNotificationTime = date + state.timePicker?.time = date + return updatePushNotificationSettingsEffect(settings: settings(from: state)) + case .loading: + break + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + .ifLet(\.$timePicker, action: \.timePicker) { + TimePickerFeature() + } + } +} + +private struct TimePickerFeature: Reducer { + typealias State = PushNotificationSettingsFeature.TimePickerState + typealias Action = PushNotificationSettingsFeature.Action.TimePicker + + var body: some ReducerOf { + BindingReducer() + } +} + +extension DependencyValues { + var fetchPushSettingsUseCase: FetchPushSettingsUseCase { + get { self[FetchPushSettingsUseCaseKey.self] } + set { self[FetchPushSettingsUseCaseKey.self] = newValue } + } + + var updatePushSettingsUseCase: UpdatePushSettingsUseCase { + get { self[UpdatePushSettingsUseCaseKey.self] } + set { self[UpdatePushSettingsUseCaseKey.self] = newValue } + } +} + +private enum FetchPushSettingsUseCaseKey: DependencyKey { + static var liveValue: FetchPushSettingsUseCase { + preconditionFailure("FetchPushSettingsUseCase must be provided.") + } + + static var testValue: FetchPushSettingsUseCase { + liveValue + } +} + +private enum UpdatePushSettingsUseCaseKey: DependencyKey { + static var liveValue: UpdatePushSettingsUseCase { + preconditionFailure("UpdatePushSettingsUseCase must be provided.") + } + + static var testValue: UpdatePushSettingsUseCase { + liveValue + } +} + +private extension PushNotificationSettingsFeature { + func fetchPushNotificationSettingsEffect() -> Effect { + .run { [fetchPushSettingsUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) + do { + let settings = try await fetchPushSettingsUseCase.execute() + await send(.binding(.set(\.pushNotificationEnable, settings.isEnabled))) + if let hour = settings.scheduledTime.hour, + let minute = settings.scheduledTime.minute, + let date = Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) { + await send(.binding(.set(\.viewPushNotificationTime, date))) + } + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.loading(.end(target: .default, mode: .delayed))) + await send(.setAlert) + } + } + } + + func updatePushNotificationSettingsEffect(settings: PushNotificationSettings) -> Effect { + .run { [updatePushSettingsUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) + do { + try await updatePushSettingsUseCase.execute(settings) + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.loading(.end(target: .default, mode: .delayed))) + await send(.setAlert) + await send(.fetchSettings) + } + } + } + + func settings(from state: State) -> PushNotificationSettings { + let date = state.timePicker?.time ?? state.viewPushNotificationTime + let dateComponents = Calendar.current.dateComponents([.hour, .minute], from: date) + return PushNotificationSettings( + isEnabled: state.pushNotificationEnable, + scheduledTime: dateComponents + ) + } + + func alertState() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "common_error_message")) + } + } +} diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift index 3ec43a07..d7847c98 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift @@ -6,22 +6,18 @@ // import SwiftUI -import DevLogDomain +import ComposableArchitecture struct PushNotificationSettingsView: View { - @State var viewModel: PushNotificationSettingsViewModel + @Bindable var store: StoreOf var body: some View { List { Section(content: { - Toggle(isOn: - Binding( - get: { viewModel.state.pushNotificationEnable }, - set: { viewModel.send(.setPushNotificationEnable($0)) } - )) { - Text(String(localized: "push_settings_enable")) - } - .tint(.blue) + Toggle(isOn: $store.pushNotificationEnable) { + Text(String(localized: "push_settings_enable")) + } + .tint(.blue) }, footer: { Text(String(localized: "push_settings_footer")) }) @@ -31,81 +27,39 @@ struct PushNotificationSettingsView: View { HStack { Text(formattedTimeString(date)) Spacer() - if viewModel.state.pushNotificationHour == hour && - viewModel.state.pushNotificationMinute == 0 { + if store.pushNotificationHour == hour && + store.pushNotificationMinute == 0 { Image(systemName: "checkmark") .foregroundStyle(Color.blue) } } .contentShape(Rectangle()) - .onTapGesture { - viewModel.send(.selectPresetTime(date)) - } + .onTapGesture { store.send(.selectPresetTime(date)) } } } HStack { Text(String(localized: "push_settings_custom")) Spacer() - Text(formattedTimeString(viewModel.state.viewPushNotificationTime)) + Text(formattedTimeString(store.viewPushNotificationTime)) .foregroundStyle(.secondary) - if viewModel.state.pushNotificationMinute != 0 { + if store.pushNotificationMinute != 0 { Image(systemName: "checkmark") .foregroundStyle(Color.blue) } } .contentShape(Rectangle()) - .onTapGesture { - viewModel.send(.setShowTimePicker(true)) - } + .onTapGesture { store.send(.tapCustomTime) } } - .disabled(!viewModel.state.pushNotificationEnable) - .opacity(viewModel.state.pushNotificationEnable ? 1.0 : 0.2) + .disabled(!store.pushNotificationEnable) + .opacity(store.pushNotificationEnable ? 1.0 : 0.2) } .listStyle(.insetGrouped) .navigationTitle(String(localized: "nav_push_settings")) - .overlay { - if viewModel.state.isLoading { - LoadingView() - } - } - .onAppear { - viewModel.send(.fetchSettings) - } - .sheet(isPresented: Binding( - get: { viewModel.state.showTimePicker }, - set: { viewModel.send(.setShowTimePicker($0)) } - )) { - NavigationStack { - DatePicker( - "", - selection: Binding( - get: { viewModel.state.sheetPushNotificationTime }, - set: { viewModel.send(.setPushNotificationTime(sheet: $0)) } - ), - displayedComponents: .hourAndMinute - ) - .datePickerStyle(.wheel) - .labelsHidden() - .onAppear { UIDatePicker.appearance().minuteInterval = 5 } - .onDisappear { UIDatePicker.appearance().minuteInterval = 1 /* 기본값으로 복원 */ } - .toolbar { - ToolbarLeadingButton { - viewModel.send(.rollbackUpdate) - } - ToolbarTrailingButton { - viewModel.send(.confirmUpdate) - } - } - .background( - GeometryReader { geometry in - Color.clear.onAppear { - viewModel.send(.setSheetHeight(geometry.size.height)) - } - } - ) - } - .presentationDragIndicator(.hidden) - .presentationDetents([.height(viewModel.state.sheetHeight)]) + .overlay { if store.isLoading { LoadingView() } } + .onAppear { store.send(.fetchSettings) } + .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(item: $store.scope(state: \.timePicker, action: \.timePicker)) { store in + TimePickerView(store: store) } } @@ -113,3 +67,41 @@ struct PushNotificationSettingsView: View { date.formatted(.dateTime.hour().minute()) } } + +private struct TimePickerView: View { + @Bindable var store: Store< + PushNotificationSettingsFeature.TimePickerState, + PushNotificationSettingsFeature.Action.TimePicker + > + + var body: some View { + NavigationStack { + DatePicker( + "", + selection: $store.time, + displayedComponents: .hourAndMinute + ) + .datePickerStyle(.wheel) + .labelsHidden() + .onAppear { UIDatePicker.appearance().minuteInterval = 5 } + .onDisappear { UIDatePicker.appearance().minuteInterval = 1 /* 기본값으로 복원 */ } + .toolbar { + ToolbarLeadingButton { + store.send(.tapCloseButton) + } + ToolbarTrailingButton { + store.send(.tapDoneButton) + } + } + .background( + GeometryReader { geometry in + Color.clear.onAppear { + store.send(.binding(.set(\.height, geometry.size.height))) + } + } + ) + } + .presentationDragIndicator(.hidden) + .presentationDetents([.height(store.height)]) + } +} diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsViewModel.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsViewModel.swift deleted file mode 100644 index 32572124..00000000 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsViewModel.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// PushNotificationSettingsViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 1/18/26. -// - -import Foundation -import DevLogDomain - -@Observable -final class PushNotificationSettingsViewModel: StorePattern { - struct State: Equatable { - var pushNotificationEnable: Bool = false - var viewPushNotificationTime: Date = .init() - var sheetPushNotificationTime: Date = .init() - var showTimePicker: Bool = false - var isLoading: Bool = false - var sheetHeight: CGFloat = .pi - var showSheet: Bool = false - var showAlert: Bool = false - var alertTitle: String = "" - var alertMessage: String = "" - var pushNotificationHour: Int { - Calendar.current.component(.hour, from: viewPushNotificationTime) - } - var pushNotificationMinute: Int { - Calendar.current.component(.minute, from: viewPushNotificationTime) - } - } - - enum Action { - case fetchSettings - case setAlert(Bool) - case setLoading(Bool) - case setPushNotificationEnable(Bool) - case setPushNotificationTime(view: Date? = nil, sheet: Date? = nil) - case setShowTimePicker(Bool) - case setSheetHeight(CGFloat) - case selectPresetTime(Date) - case confirmUpdate - case rollbackUpdate - } - - enum SideEffect { - case fetchPushNotificationSettings - case updatePushNotificationSettings - } - - private(set) var state: State = .init() - private let calendar = Calendar.current - private let fetchPushSettingsUseCase: FetchPushSettingsUseCase - private let updatePushSettingsUseCase: UpdatePushSettingsUseCase - private let loadingState = LoadingState() - - init( - fetchPushSettingsUseCase: FetchPushSettingsUseCase, - updatePushSettingsUseCase: UpdatePushSettingsUseCase - ) { - self.fetchPushSettingsUseCase = fetchPushSettingsUseCase - self.updatePushSettingsUseCase = updatePushSettingsUseCase - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .fetchSettings: - effects = [.fetchPushNotificationSettings] - case .setAlert(let isPresented): - setAlert(&state, isPresented: isPresented) - case .setLoading(let value): - state.isLoading = value - case .setPushNotificationEnable(let value): - state.pushNotificationEnable = value - effects = [.updatePushNotificationSettings] - case .setPushNotificationTime(let view, let sheet): - if let value = view { - state.viewPushNotificationTime = value - } - if let value = sheet { - state.sheetPushNotificationTime = value - } - case .setShowTimePicker(let value): - state.showTimePicker = value - if !value { - state.sheetPushNotificationTime = state.viewPushNotificationTime - } - case .setSheetHeight(let value): - state.sheetHeight = value - case .selectPresetTime(let date): - state.viewPushNotificationTime = date - state.sheetPushNotificationTime = date - effects = [.updatePushNotificationSettings] - case .confirmUpdate: - state.showTimePicker = false - state.viewPushNotificationTime = state.sheetPushNotificationTime - effects = [.updatePushNotificationSettings] - case .rollbackUpdate: - state.showTimePicker = false - state.sheetPushNotificationTime = state.viewPushNotificationTime - } - if self.state != state { self.state = state } - return effects - } - - func run(_ effect: SideEffect) { - switch effect { - case .fetchPushNotificationSettings: - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - let settings = try await fetchPushSettingsUseCase.execute() - self.send(.setPushNotificationEnable(settings.isEnabled)) - if let hour = settings.scheduledTime.hour, - let minute = settings.scheduledTime.minute, - let date = calendar.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) { - self.send(.setPushNotificationTime(view: date, sheet: date)) - } - } catch { - send(.setAlert(true)) - } - } - case .updatePushNotificationSettings: - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - let dateComponents = calendar.dateComponents( - [.hour, .minute], - from: state.sheetPushNotificationTime - ) - let settings = PushNotificationSettings( - isEnabled: state.pushNotificationEnable, - scheduledTime: dateComponents - ) - try await updatePushSettingsUseCase.execute(settings) - } catch { - send(.setAlert(true)) - send(.fetchSettings) - } - } - } - } -} - -extension PushNotificationSettingsViewModel { - func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - state.showAlert = isPresented - } - - private func beginLoading(_ mode: LoadingState.Mode) { - loadingState.begin(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } - - private func endLoading(_ mode: LoadingState.Mode) { - loadingState.end(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } -} From 4170a432a26f0252153cc602301b3084e2d53ccb Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:45:19 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20PushNotificationSettingsFeature=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...PushNotificationSettingsFeatureTests.swift | 395 ++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift diff --git a/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift b/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift new file mode 100644 index 00000000..8eea3e01 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift @@ -0,0 +1,395 @@ +// +// PushNotificationSettingsFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import Testing +import ComposableArchitecture +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct PushNotificationSettingsFeatureTests { + @Test("fetchSettings는 푸시 설정 상태를 갱신한다") + func fetchSettings는_푸시_설정_상태를_갱신한다() async { + let fetchSpy = FetchPushSettingsUseCaseSpy( + settings: makePushNotificationSettings(isEnabled: true, hour: 9, minute: 0) + ) + let adapter = PushNotificationSettingsStoreTestAdapter(fetchUseCase: fetchSpy) + + await adapter.fetchSettings() + + #expect(adapter.pushNotificationEnable) + #expect(adapter.pushNotificationHour == 9) + #expect(adapter.pushNotificationMinute == 0) + #expect(adapter.sheetPushNotificationTime == adapter.viewPushNotificationTime) + } + + @Test("setPushNotificationEnable은 활성화 상태를 변경한다") + func setPushNotificationEnable은_활성화_상태를_변경한다() async { + let adapter = PushNotificationSettingsStoreTestAdapter() + + await adapter.setPushNotificationEnable(true) + + #expect(adapter.pushNotificationEnable) + } + + @Test("selectPresetTime은 화면과 시트 시간을 함께 변경한다") + func selectPresetTime은_화면과_시트_시간을_함께_변경한다() async { + let adapter = PushNotificationSettingsStoreTestAdapter() + let date = makeDate(hour: 15, minute: 0) + + await adapter.selectPresetTime(date) + + #expect(adapter.viewPushNotificationTime == date) + #expect(adapter.sheetPushNotificationTime == date) + #expect(adapter.pushNotificationHour == 15) + #expect(adapter.pushNotificationMinute == 0) + } + + @Test("setShowTimePicker는 현재 화면 시간으로 시트를 연다") + func setShowTimePicker는_현재_화면_시간으로_시트를_연다() async { + let adapter = PushNotificationSettingsStoreTestAdapter() + let date = makeDate(hour: 18, minute: 0) + + await adapter.setPushNotificationTime(view: date) + await adapter.setShowTimePicker(true) + + #expect(adapter.showTimePicker) + #expect(adapter.sheetPushNotificationTime == date) + } + + @Test("시트 시간 변경은 확정 전까지 화면 시간을 변경하지 않는다") + func 시트_시간_변경은_확정_전까지_화면_시간을_변경하지_않는다() async { + let adapter = PushNotificationSettingsStoreTestAdapter() + let viewDate = makeDate(hour: 9, minute: 0) + let sheetDate = makeDate(hour: 10, minute: 35) + + await adapter.setPushNotificationTime(view: viewDate) + await adapter.setShowTimePicker(true) + await adapter.setPushNotificationTime(sheet: sheetDate) + + #expect(adapter.viewPushNotificationTime == viewDate) + #expect(adapter.sheetPushNotificationTime == sheetDate) + } + + @Test("confirmUpdate는 시트 시간을 화면 시간에 반영하고 시트를 닫는다") + func confirmUpdate는_시트_시간을_화면_시간에_반영하고_시트를_닫는다() async { + let adapter = PushNotificationSettingsStoreTestAdapter() + let viewDate = makeDate(hour: 9, minute: 0) + let sheetDate = makeDate(hour: 10, minute: 35) + + await adapter.setPushNotificationTime(view: viewDate) + await adapter.setShowTimePicker(true) + await adapter.setPushNotificationTime(sheet: sheetDate) + await adapter.confirmUpdate() + + #expect(!adapter.showTimePicker) + #expect(adapter.viewPushNotificationTime == sheetDate) + #expect(adapter.sheetPushNotificationTime == sheetDate) + } + + @Test("rollbackUpdate는 화면 시간을 유지하고 시트를 닫는다") + func rollbackUpdate는_화면_시간을_유지하고_시트를_닫는다() async { + let adapter = PushNotificationSettingsStoreTestAdapter() + let viewDate = makeDate(hour: 9, minute: 0) + let sheetDate = makeDate(hour: 10, minute: 35) + + await adapter.setPushNotificationTime(view: viewDate) + await adapter.setShowTimePicker(true) + await adapter.setPushNotificationTime(sheet: sheetDate) + await adapter.rollbackUpdate() + + #expect(!adapter.showTimePicker) + #expect(adapter.viewPushNotificationTime == viewDate) + #expect(adapter.sheetPushNotificationTime == viewDate) + } + + @Test("setSheetHeight는 시트 높이 상태를 변경한다") + func setSheetHeight는_시트_높이_상태를_변경한다() async { + let adapter = PushNotificationSettingsStoreTestAdapter() + + await adapter.setShowTimePicker(true) + await adapter.setSheetHeight(240) + + #expect(adapter.sheetHeight == 240) + } + + @Test("푸시 설정 조회가 지연되면 로딩 상태를 표시하고 완료되면 해제한다") + func 푸시_설정_조회가_지연되면_로딩_상태를_표시하고_완료되면_해제한다() async { + let clock = TestClock() + let fetchSpy = FetchPushSettingsUseCaseSpy() + fetchSpy.shouldSuspend = true + let adapter = PushNotificationSettingsStoreTestAdapter( + fetchUseCase: fetchSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) + + await adapter.fetchSettings() + + #expect(fetchSpy.executeCallCount == 1) + #expect(!adapter.isLoading) + + await clock.advance(by: .milliseconds(300)) + await adapter.receiveDelayedLoading() + + #expect(adapter.isLoading) + + fetchSpy.resume() + await adapter.drainReceivedActions() + + #expect(!adapter.isLoading) + #expect(adapter.pushNotificationHour == 9) + } + + @Test("푸시 설정 조회에 실패하면 공통 에러 알림을 표시한다") + func 푸시_설정_조회에_실패하면_공통_에러_알림을_표시한다() async { + let fetchSpy = FetchPushSettingsUseCaseSpy() + fetchSpy.error = PushNotificationSettingsTestError.failure + let adapter = PushNotificationSettingsStoreTestAdapter(fetchUseCase: fetchSpy) + + await adapter.fetchSettings() + + #expect(adapter.alert == expectedPushNotificationSettingsErrorAlert()) + } + + @Test("설정 업데이트에 실패하면 알림을 표시하고 서버 상태로 되돌린다") + func 설정_업데이트에_실패하면_알림을_표시하고_서버_상태로_되돌린다() async { + let fetchSpy = FetchPushSettingsUseCaseSpy( + settings: makePushNotificationSettings(isEnabled: true, hour: 9, minute: 0) + ) + let updateSpy = UpdatePushSettingsUseCaseSpy() + updateSpy.error = PushNotificationSettingsTestError.failure + let adapter = PushNotificationSettingsStoreTestAdapter( + fetchUseCase: fetchSpy, + updateUseCase: updateSpy + ) + let date = makeDate(hour: 21, minute: 0) + + await adapter.selectPresetTime(date) + + #expect(adapter.alert == expectedPushNotificationSettingsErrorAlert()) + #expect(adapter.pushNotificationEnable) + #expect(adapter.pushNotificationHour == 9) + #expect(adapter.pushNotificationMinute == 0) + } +} + +@MainActor +private struct PushNotificationSettingsStoreTestAdapter { + private let store: TestStoreOf + + var pushNotificationEnable: Bool { store.state.pushNotificationEnable } + var viewPushNotificationTime: Date { store.state.viewPushNotificationTime } + var sheetPushNotificationTime: Date { store.state.timePicker?.time ?? store.state.viewPushNotificationTime } + var showTimePicker: Bool { store.state.timePicker != nil } + var isLoading: Bool { store.state.isLoading } + var sheetHeight: CGFloat { store.state.timePicker?.height ?? .pi } + var alert: AlertState? { store.state.alert } + var pushNotificationHour: Int { store.state.pushNotificationHour } + var pushNotificationMinute: Int { store.state.pushNotificationMinute } + + init( + fetchUseCase: FetchPushSettingsUseCase = FetchPushSettingsUseCaseSpy(), + updateUseCase: UpdatePushSettingsUseCase = UpdatePushSettingsUseCaseSpy(), + configureDependencies: ((inout DependencyValues) -> Void)? = nil + ) { + store = TestStore(initialState: PushNotificationSettingsFeature.State()) { + PushNotificationSettingsFeature() + } withDependencies: { + $0.fetchPushSettingsUseCase = fetchUseCase + $0.updatePushSettingsUseCase = updateUseCase + $0.continuousClock = ContinuousClock() + configureDependencies?(&$0) + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + func fetchSettings() async { + await store.send(.fetchSettings) + await drainReceivedActions() + } + + func setPushNotificationEnable(_ value: Bool) async { + await store.send(.binding(.set(\.pushNotificationEnable, value))) { + $0.pushNotificationEnable = value + } + await drainReceivedActions() + } + + func setPushNotificationTime(view: Date?) async { + guard let view else { return } + await store.send(.binding(.set(\.viewPushNotificationTime, view))) { + $0.viewPushNotificationTime = view + $0.timePicker?.time = view + } + } + + func setPushNotificationTime(sheet: Date?) async { + guard let sheet else { return } + await store.send(.timePicker(.presented(.binding(.set(\.time, sheet))))) { + $0.timePicker?.time = sheet + } + } + + func setShowTimePicker(_ value: Bool) async { + if value { + await store.send(.tapCustomTime) { + $0.timePicker = PushNotificationSettingsFeature.TimePickerState( + time: $0.viewPushNotificationTime + ) + } + } else { + await store.send(.timePicker(.dismiss)) { + $0.timePicker = nil + } + } + } + + func setSheetHeight(_ value: CGFloat) async { + await store.send(.timePicker(.presented(.binding(.set(\.height, value))))) { + $0.timePicker?.height = value + } + } + + func selectPresetTime(_ date: Date) async { + await store.send(.selectPresetTime(date)) { + $0.viewPushNotificationTime = date + $0.timePicker?.time = date + } + await drainReceivedActions() + } + + func confirmUpdate() async { + let time = store.state.timePicker?.time + await store.send(.timePicker(.presented(.tapDoneButton))) { + $0.timePicker = nil + if let time { + $0.viewPushNotificationTime = time + } + } + await drainReceivedActions() + } + + func rollbackUpdate() async { + await store.send(.timePicker(.presented(.tapCloseButton))) { + $0.timePicker = nil + } + } + + func receiveDelayedLoading() async { + let target = LoadingFeature.Target.default + await store.receive(\.loading.delayedLoadingDidBecomeVisible, target) { + $0.loading.scheduledDelayedTargets = [] + $0.loading.visibleDelayedTargets = [target] + $0.loading.visibleTargets = [target] + $0.loading.isLoading = true + } + } + + func drainReceivedActions() async { + await store.skipReceivedActions(strict: false) + await store.skipReceivedActions(strict: false) + await store.skipReceivedActions(strict: false) + } +} + +private final class FetchPushSettingsUseCaseSpy: FetchPushSettingsUseCase { + var settings: PushNotificationSettings + var error: Error? + var shouldSuspend = false + private(set) var executeCallCount = 0 + private var continuation: CheckedContinuation? + private var shouldResume = false + + init(settings: PushNotificationSettings = makePushNotificationSettings()) { + self.settings = settings + } + + func execute() async throws -> PushNotificationSettings { + executeCallCount += 1 + + if shouldSuspend { + await withCheckedContinuation { continuation in + if shouldResume { + shouldResume = false + continuation.resume() + } else { + self.continuation = continuation + } + } + } + + if let error { + throw error + } + + return settings + } + + func resume() { + guard let continuation else { + shouldResume = true + return + } + + self.continuation = nil + continuation.resume() + } +} + +private final class UpdatePushSettingsUseCaseSpy: UpdatePushSettingsUseCase { + var error: Error? + + func execute(_: PushNotificationSettings) async throws { + if let error { + self.error = nil + throw error + } + } +} + +private enum PushNotificationSettingsTestError: Error { + case failure +} + +private func makePushNotificationSettings( + isEnabled: Bool = true, + hour: Int = 9, + minute: Int = 0 +) -> PushNotificationSettings { + PushNotificationSettings( + isEnabled: isEnabled, + scheduledTime: DateComponents(hour: hour, minute: minute) + ) +} + +private func makeDate( + hour: Int, + minute: Int +) -> Date { + let baseDate = Date(timeIntervalSince1970: 0) + return Calendar.current.date( + bySettingHour: hour, + minute: minute, + second: 0, + of: baseDate + ) ?? baseDate +} + +private func expectedPushNotificationSettingsErrorAlert() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "common_error_message")) + } +}