Skip to content

Commit 4170a43

Browse files
committed
test: PushNotificationSettingsFeature 테스트 추가
1 parent 48e3bd2 commit 4170a43

1 file changed

Lines changed: 395 additions & 0 deletions

File tree

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
//
2+
// PushNotificationSettingsFeatureTests.swift
3+
// DevLogPresentationTests
4+
//
5+
// Created by opfic on 6/12/26.
6+
//
7+
8+
import Testing
9+
import ComposableArchitecture
10+
import Foundation
11+
import DevLogDomain
12+
@testable import DevLogPresentation
13+
14+
@MainActor
15+
struct PushNotificationSettingsFeatureTests {
16+
@Test("fetchSettings는 푸시 설정 상태를 갱신한다")
17+
func fetchSettings는_푸시_설정_상태를_갱신한다() async {
18+
let fetchSpy = FetchPushSettingsUseCaseSpy(
19+
settings: makePushNotificationSettings(isEnabled: true, hour: 9, minute: 0)
20+
)
21+
let adapter = PushNotificationSettingsStoreTestAdapter(fetchUseCase: fetchSpy)
22+
23+
await adapter.fetchSettings()
24+
25+
#expect(adapter.pushNotificationEnable)
26+
#expect(adapter.pushNotificationHour == 9)
27+
#expect(adapter.pushNotificationMinute == 0)
28+
#expect(adapter.sheetPushNotificationTime == adapter.viewPushNotificationTime)
29+
}
30+
31+
@Test("setPushNotificationEnable은 활성화 상태를 변경한다")
32+
func setPushNotificationEnable은_활성화_상태를_변경한다() async {
33+
let adapter = PushNotificationSettingsStoreTestAdapter()
34+
35+
await adapter.setPushNotificationEnable(true)
36+
37+
#expect(adapter.pushNotificationEnable)
38+
}
39+
40+
@Test("selectPresetTime은 화면과 시트 시간을 함께 변경한다")
41+
func selectPresetTime은_화면과_시트_시간을_함께_변경한다() async {
42+
let adapter = PushNotificationSettingsStoreTestAdapter()
43+
let date = makeDate(hour: 15, minute: 0)
44+
45+
await adapter.selectPresetTime(date)
46+
47+
#expect(adapter.viewPushNotificationTime == date)
48+
#expect(adapter.sheetPushNotificationTime == date)
49+
#expect(adapter.pushNotificationHour == 15)
50+
#expect(adapter.pushNotificationMinute == 0)
51+
}
52+
53+
@Test("setShowTimePicker는 현재 화면 시간으로 시트를 연다")
54+
func setShowTimePicker는_현재_화면_시간으로_시트를_연다() async {
55+
let adapter = PushNotificationSettingsStoreTestAdapter()
56+
let date = makeDate(hour: 18, minute: 0)
57+
58+
await adapter.setPushNotificationTime(view: date)
59+
await adapter.setShowTimePicker(true)
60+
61+
#expect(adapter.showTimePicker)
62+
#expect(adapter.sheetPushNotificationTime == date)
63+
}
64+
65+
@Test("시트 시간 변경은 확정 전까지 화면 시간을 변경하지 않는다")
66+
func 시트_시간_변경은_확정_전까지_화면_시간을_변경하지_않는다() async {
67+
let adapter = PushNotificationSettingsStoreTestAdapter()
68+
let viewDate = makeDate(hour: 9, minute: 0)
69+
let sheetDate = makeDate(hour: 10, minute: 35)
70+
71+
await adapter.setPushNotificationTime(view: viewDate)
72+
await adapter.setShowTimePicker(true)
73+
await adapter.setPushNotificationTime(sheet: sheetDate)
74+
75+
#expect(adapter.viewPushNotificationTime == viewDate)
76+
#expect(adapter.sheetPushNotificationTime == sheetDate)
77+
}
78+
79+
@Test("confirmUpdate는 시트 시간을 화면 시간에 반영하고 시트를 닫는다")
80+
func confirmUpdate는_시트_시간을_화면_시간에_반영하고_시트를_닫는다() async {
81+
let adapter = PushNotificationSettingsStoreTestAdapter()
82+
let viewDate = makeDate(hour: 9, minute: 0)
83+
let sheetDate = makeDate(hour: 10, minute: 35)
84+
85+
await adapter.setPushNotificationTime(view: viewDate)
86+
await adapter.setShowTimePicker(true)
87+
await adapter.setPushNotificationTime(sheet: sheetDate)
88+
await adapter.confirmUpdate()
89+
90+
#expect(!adapter.showTimePicker)
91+
#expect(adapter.viewPushNotificationTime == sheetDate)
92+
#expect(adapter.sheetPushNotificationTime == sheetDate)
93+
}
94+
95+
@Test("rollbackUpdate는 화면 시간을 유지하고 시트를 닫는다")
96+
func rollbackUpdate는_화면_시간을_유지하고_시트를_닫는다() async {
97+
let adapter = PushNotificationSettingsStoreTestAdapter()
98+
let viewDate = makeDate(hour: 9, minute: 0)
99+
let sheetDate = makeDate(hour: 10, minute: 35)
100+
101+
await adapter.setPushNotificationTime(view: viewDate)
102+
await adapter.setShowTimePicker(true)
103+
await adapter.setPushNotificationTime(sheet: sheetDate)
104+
await adapter.rollbackUpdate()
105+
106+
#expect(!adapter.showTimePicker)
107+
#expect(adapter.viewPushNotificationTime == viewDate)
108+
#expect(adapter.sheetPushNotificationTime == viewDate)
109+
}
110+
111+
@Test("setSheetHeight는 시트 높이 상태를 변경한다")
112+
func setSheetHeight는_시트_높이_상태를_변경한다() async {
113+
let adapter = PushNotificationSettingsStoreTestAdapter()
114+
115+
await adapter.setShowTimePicker(true)
116+
await adapter.setSheetHeight(240)
117+
118+
#expect(adapter.sheetHeight == 240)
119+
}
120+
121+
@Test("푸시 설정 조회가 지연되면 로딩 상태를 표시하고 완료되면 해제한다")
122+
func 푸시_설정_조회가_지연되면_로딩_상태를_표시하고_완료되면_해제한다() async {
123+
let clock = TestClock()
124+
let fetchSpy = FetchPushSettingsUseCaseSpy()
125+
fetchSpy.shouldSuspend = true
126+
let adapter = PushNotificationSettingsStoreTestAdapter(
127+
fetchUseCase: fetchSpy,
128+
configureDependencies: {
129+
$0.continuousClock = clock
130+
}
131+
)
132+
133+
await adapter.fetchSettings()
134+
135+
#expect(fetchSpy.executeCallCount == 1)
136+
#expect(!adapter.isLoading)
137+
138+
await clock.advance(by: .milliseconds(300))
139+
await adapter.receiveDelayedLoading()
140+
141+
#expect(adapter.isLoading)
142+
143+
fetchSpy.resume()
144+
await adapter.drainReceivedActions()
145+
146+
#expect(!adapter.isLoading)
147+
#expect(adapter.pushNotificationHour == 9)
148+
}
149+
150+
@Test("푸시 설정 조회에 실패하면 공통 에러 알림을 표시한다")
151+
func 푸시_설정_조회에_실패하면_공통_에러_알림을_표시한다() async {
152+
let fetchSpy = FetchPushSettingsUseCaseSpy()
153+
fetchSpy.error = PushNotificationSettingsTestError.failure
154+
let adapter = PushNotificationSettingsStoreTestAdapter(fetchUseCase: fetchSpy)
155+
156+
await adapter.fetchSettings()
157+
158+
#expect(adapter.alert == expectedPushNotificationSettingsErrorAlert())
159+
}
160+
161+
@Test("설정 업데이트에 실패하면 알림을 표시하고 서버 상태로 되돌린다")
162+
func 설정_업데이트에_실패하면_알림을_표시하고_서버_상태로_되돌린다() async {
163+
let fetchSpy = FetchPushSettingsUseCaseSpy(
164+
settings: makePushNotificationSettings(isEnabled: true, hour: 9, minute: 0)
165+
)
166+
let updateSpy = UpdatePushSettingsUseCaseSpy()
167+
updateSpy.error = PushNotificationSettingsTestError.failure
168+
let adapter = PushNotificationSettingsStoreTestAdapter(
169+
fetchUseCase: fetchSpy,
170+
updateUseCase: updateSpy
171+
)
172+
let date = makeDate(hour: 21, minute: 0)
173+
174+
await adapter.selectPresetTime(date)
175+
176+
#expect(adapter.alert == expectedPushNotificationSettingsErrorAlert())
177+
#expect(adapter.pushNotificationEnable)
178+
#expect(adapter.pushNotificationHour == 9)
179+
#expect(adapter.pushNotificationMinute == 0)
180+
}
181+
}
182+
183+
@MainActor
184+
private struct PushNotificationSettingsStoreTestAdapter {
185+
private let store: TestStoreOf<PushNotificationSettingsFeature>
186+
187+
var pushNotificationEnable: Bool { store.state.pushNotificationEnable }
188+
var viewPushNotificationTime: Date { store.state.viewPushNotificationTime }
189+
var sheetPushNotificationTime: Date { store.state.timePicker?.time ?? store.state.viewPushNotificationTime }
190+
var showTimePicker: Bool { store.state.timePicker != nil }
191+
var isLoading: Bool { store.state.isLoading }
192+
var sheetHeight: CGFloat { store.state.timePicker?.height ?? .pi }
193+
var alert: AlertState<Never>? { store.state.alert }
194+
var pushNotificationHour: Int { store.state.pushNotificationHour }
195+
var pushNotificationMinute: Int { store.state.pushNotificationMinute }
196+
197+
init(
198+
fetchUseCase: FetchPushSettingsUseCase = FetchPushSettingsUseCaseSpy(),
199+
updateUseCase: UpdatePushSettingsUseCase = UpdatePushSettingsUseCaseSpy(),
200+
configureDependencies: ((inout DependencyValues) -> Void)? = nil
201+
) {
202+
store = TestStore(initialState: PushNotificationSettingsFeature.State()) {
203+
PushNotificationSettingsFeature()
204+
} withDependencies: {
205+
$0.fetchPushSettingsUseCase = fetchUseCase
206+
$0.updatePushSettingsUseCase = updateUseCase
207+
$0.continuousClock = ContinuousClock()
208+
configureDependencies?(&$0)
209+
}
210+
store.exhaustivity = .off(showSkippedAssertions: false)
211+
}
212+
213+
func fetchSettings() async {
214+
await store.send(.fetchSettings)
215+
await drainReceivedActions()
216+
}
217+
218+
func setPushNotificationEnable(_ value: Bool) async {
219+
await store.send(.binding(.set(\.pushNotificationEnable, value))) {
220+
$0.pushNotificationEnable = value
221+
}
222+
await drainReceivedActions()
223+
}
224+
225+
func setPushNotificationTime(view: Date?) async {
226+
guard let view else { return }
227+
await store.send(.binding(.set(\.viewPushNotificationTime, view))) {
228+
$0.viewPushNotificationTime = view
229+
$0.timePicker?.time = view
230+
}
231+
}
232+
233+
func setPushNotificationTime(sheet: Date?) async {
234+
guard let sheet else { return }
235+
await store.send(.timePicker(.presented(.binding(.set(\.time, sheet))))) {
236+
$0.timePicker?.time = sheet
237+
}
238+
}
239+
240+
func setShowTimePicker(_ value: Bool) async {
241+
if value {
242+
await store.send(.tapCustomTime) {
243+
$0.timePicker = PushNotificationSettingsFeature.TimePickerState(
244+
time: $0.viewPushNotificationTime
245+
)
246+
}
247+
} else {
248+
await store.send(.timePicker(.dismiss)) {
249+
$0.timePicker = nil
250+
}
251+
}
252+
}
253+
254+
func setSheetHeight(_ value: CGFloat) async {
255+
await store.send(.timePicker(.presented(.binding(.set(\.height, value))))) {
256+
$0.timePicker?.height = value
257+
}
258+
}
259+
260+
func selectPresetTime(_ date: Date) async {
261+
await store.send(.selectPresetTime(date)) {
262+
$0.viewPushNotificationTime = date
263+
$0.timePicker?.time = date
264+
}
265+
await drainReceivedActions()
266+
}
267+
268+
func confirmUpdate() async {
269+
let time = store.state.timePicker?.time
270+
await store.send(.timePicker(.presented(.tapDoneButton))) {
271+
$0.timePicker = nil
272+
if let time {
273+
$0.viewPushNotificationTime = time
274+
}
275+
}
276+
await drainReceivedActions()
277+
}
278+
279+
func rollbackUpdate() async {
280+
await store.send(.timePicker(.presented(.tapCloseButton))) {
281+
$0.timePicker = nil
282+
}
283+
}
284+
285+
func receiveDelayedLoading() async {
286+
let target = LoadingFeature.Target.default
287+
await store.receive(\.loading.delayedLoadingDidBecomeVisible, target) {
288+
$0.loading.scheduledDelayedTargets = []
289+
$0.loading.visibleDelayedTargets = [target]
290+
$0.loading.visibleTargets = [target]
291+
$0.loading.isLoading = true
292+
}
293+
}
294+
295+
func drainReceivedActions() async {
296+
await store.skipReceivedActions(strict: false)
297+
await store.skipReceivedActions(strict: false)
298+
await store.skipReceivedActions(strict: false)
299+
}
300+
}
301+
302+
private final class FetchPushSettingsUseCaseSpy: FetchPushSettingsUseCase {
303+
var settings: PushNotificationSettings
304+
var error: Error?
305+
var shouldSuspend = false
306+
private(set) var executeCallCount = 0
307+
private var continuation: CheckedContinuation<Void, Never>?
308+
private var shouldResume = false
309+
310+
init(settings: PushNotificationSettings = makePushNotificationSettings()) {
311+
self.settings = settings
312+
}
313+
314+
func execute() async throws -> PushNotificationSettings {
315+
executeCallCount += 1
316+
317+
if shouldSuspend {
318+
await withCheckedContinuation { continuation in
319+
if shouldResume {
320+
shouldResume = false
321+
continuation.resume()
322+
} else {
323+
self.continuation = continuation
324+
}
325+
}
326+
}
327+
328+
if let error {
329+
throw error
330+
}
331+
332+
return settings
333+
}
334+
335+
func resume() {
336+
guard let continuation else {
337+
shouldResume = true
338+
return
339+
}
340+
341+
self.continuation = nil
342+
continuation.resume()
343+
}
344+
}
345+
346+
private final class UpdatePushSettingsUseCaseSpy: UpdatePushSettingsUseCase {
347+
var error: Error?
348+
349+
func execute(_: PushNotificationSettings) async throws {
350+
if let error {
351+
self.error = nil
352+
throw error
353+
}
354+
}
355+
}
356+
357+
private enum PushNotificationSettingsTestError: Error {
358+
case failure
359+
}
360+
361+
private func makePushNotificationSettings(
362+
isEnabled: Bool = true,
363+
hour: Int = 9,
364+
minute: Int = 0
365+
) -> PushNotificationSettings {
366+
PushNotificationSettings(
367+
isEnabled: isEnabled,
368+
scheduledTime: DateComponents(hour: hour, minute: minute)
369+
)
370+
}
371+
372+
private func makeDate(
373+
hour: Int,
374+
minute: Int
375+
) -> Date {
376+
let baseDate = Date(timeIntervalSince1970: 0)
377+
return Calendar.current.date(
378+
bySettingHour: hour,
379+
minute: minute,
380+
second: 0,
381+
of: baseDate
382+
) ?? baseDate
383+
}
384+
385+
private func expectedPushNotificationSettingsErrorAlert() -> AlertState<Never> {
386+
AlertState {
387+
TextState(String(localized: "common_error_title"))
388+
} actions: {
389+
ButtonState(role: .cancel) {
390+
TextState(String(localized: "common_close"))
391+
}
392+
} message: {
393+
TextState(String(localized: "common_error_message"))
394+
}
395+
}

0 commit comments

Comments
 (0)