From 9229d872fd8eaa3661e511339777d245900e751d Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 22:27:04 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=ED=99=94=EB=A9=B4=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B7=9C=EC=95=BD=20=ED=86=B5=EC=9D=BC=20+=20TaskB?= =?UTF-8?q?ox=20=EC=8B=A0=EC=84=A4=20(Phase=20E1~E4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ViewModel마다 제각각이던 상태 표현을 하나로 맞춘다. **VM당 상태 채널이 정확히 1개**가 됐다(7 → 5). ## E1 SettingsViewModel — 규약의 참조 구현 `sections`(렌더 결과)가 상태 자리에 있고 실제 상태 셋(`addressText`·`hasUpdate`· `isLoggingOut`)이 private 필드로 흩어져 있었다. State를 신설하고 `sections`를 순수 함수로 만들었다. `rebuildSections()` 수동 호출 4곳이 사라졌다 — 상태가 바뀌면 렌더 결과가 따라오는 것이 당연해졌기 때문이다. 순수 함수가 되면서 **ViewModel 인스턴스 없이** 상태 조합만으로 렌더 결과를 검증할 수 있다(신규 테스트 6개). 이전에는 UseCase 스텁을 조립하고 비동기 로딩이 끝나기를 기다려야 했다. ## E2 WithdrawViewModel — 파생을 저장하지 않는다 `canSubmit` 저장 필드와 `refreshCanSubmit()` 호출 4곳을 제거했다. 저장하면 `selected`/`otherText`/`isSubmitting`이 바뀔 때마다 갱신을 잊지 않아야 하고, 그 자체가 불일치의 원인이 된다. `otherText`도 State 안으로 넣었다 — 밖에 있으면 "선택은 기타인데 입력이 비어 있다" 같은 조합이 타입으로 표현되지 않는다. ## E3 HomeAddress·Search — 보조 콜백 제거 두 VM이 상태 채널을 둘씩 갖고 있었다(`onSavingChange`, `onFieldsChange`). 슬롯을 따로 흘리던 이유(매 키 입력마다 전체를 push하면 `UITextField` 커서가 튄다)는 정당하지만, **diff는 계약이 아니라 VC의 일이다.** VC가 직전 상태를 들고 바뀐 것만 반영한다(`rendered` 필드, `if` 한 줄). ## E4 TaskBox — 재진입 규칙을 타입으로 ViewModel마다 `Task` 필드를 나열하고(홈 9개, 검색 5개) `deinit`에서 전부 취소하며, 재진입 방지는 `guard xxxTask == nil` 수기 가드로 처리했다. 필드를 하나 늘릴 때마다 `deinit`에 한 줄을 잊지 않아야 했고, 어떤 작업이 어떤 규칙을 따르는지는 주석으로만 남아 있었다. .replaceExisting 최신 입력만 의미 있는 조회 .skipIfRunning 중복 발사가 해로운 동작 .independent 나중 것이 앞 것을 취소하면 안 되는 작업(저장) `.skipIfRunning`의 완료 정리를 테스트로 못박았다 — **이걸 잊는 것이 #366에서 고친 "당김 새로고침 영구 잠김"의 원인**이었다. `Mutex` 기반인 이유: 소유자의 `deinit`에서 일괄 취소할 수 있어야 하는데 `deinit`은 nonisolated라 액터 격리된 저장 프로퍼티를 만질 수 없다. CLAUDE.md의 "catch-all Shared/Common 금지, 목적별 모듈" 규약에 따라 `CoreConcurrency`로 신설했다. ## Swift 6 격리 모듈 기본 격리가 `.mainActor`라 파일 수준 타입까지 격리를 얻어, `Sendable` 제약을 만족하지 못하는 경우를 세 번 만났다. 전부 `nonisolated` 명시로 해결하고 이유를 주석에 남겼다. 검증: SettingsFeature 18 / SearchFeature 26 / CoreConcurrency 7 Co-Authored-By: Claude Opus 5 --- Projects/Core/Concurrency/Project.swift | 8 + .../Core/Concurrency/Sources/TaskBox.swift | 113 ++++++++++++++ .../Core/Concurrency/Tests/TaskBoxTests.swift | 146 ++++++++++++++++++ .../Search/Sources/SearchViewController.swift | 17 +- .../Search/Sources/SearchViewModel.swift | 88 ++++++----- .../Search/Tests/SearchViewModelTests.swift | 120 +++++++------- .../Sources/HomeAddressViewController.swift | 17 +- .../Sources/HomeAddressViewModel.swift | 43 +++--- .../Settings/Sources/SettingsViewModel.swift | 39 +++-- .../Settings/Sources/WithdrawViewModel.swift | 38 ++--- .../Tests/HomeAddressViewModelTests.swift | 4 +- .../Tests/SettingsSectionsTests.swift | 62 ++++++++ Workspace.swift | 1 + 13 files changed, 523 insertions(+), 173 deletions(-) create mode 100644 Projects/Core/Concurrency/Project.swift create mode 100644 Projects/Core/Concurrency/Sources/TaskBox.swift create mode 100644 Projects/Core/Concurrency/Tests/TaskBoxTests.swift create mode 100644 Projects/Feature/Settings/Tests/SettingsSectionsTests.swift diff --git a/Projects/Core/Concurrency/Project.swift b/Projects/Core/Concurrency/Project.swift new file mode 100644 index 00000000..83d6d7bc --- /dev/null +++ b/Projects/Core/Concurrency/Project.swift @@ -0,0 +1,8 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.layer( + name: "CoreConcurrency", + bundleSuffix: "core.concurrency", + isolation: .mainActor +) diff --git a/Projects/Core/Concurrency/Sources/TaskBox.swift b/Projects/Core/Concurrency/Sources/TaskBox.swift new file mode 100644 index 00000000..74f3bd47 --- /dev/null +++ b/Projects/Core/Concurrency/Sources/TaskBox.swift @@ -0,0 +1,113 @@ +import Foundation +import Synchronization + +/// ViewModel이 들고 있는 비동기 작업들을 키로 관리한다. +/// +/// 이전에는 ViewModel마다 `Task` 필드를 나열하고(홈 9개, 검색 5개) `deinit`에서 전부 +/// 취소하며, 재진입 방지는 `guard xxxTask == nil else { return }` 같은 수기 가드로 +/// 처리했다. 필드를 하나 늘릴 때마다 `deinit`에 한 줄을 잊지 않아야 했고, 어떤 작업이 +/// 어떤 재진입 규칙을 따르는지는 주석으로만 남아 있었다. +/// +/// `Policy`가 그 규칙을 타입으로 만든다. +/// `Mutex` 기반인 이유: 소유자의 `deinit`에서 일괄 취소할 수 있어야 하는데, `deinit`은 +/// nonisolated라 액터 격리된 저장 프로퍼티를 만질 수 없다. 기존 ViewModel들이 `deinit`에 +/// Task 취소를 나열하던 자리를 그대로 대체하려면 격리 밖에서도 접근 가능해야 한다. +public nonisolated final class TaskBox: @unchecked Sendable { + /// 같은 키의 작업이 이미 돌고 있을 때 무엇을 할지. + public nonisolated enum Policy: Sendable { + /// 기존 작업을 취소하고 새로 시작한다 — 최신 입력만 의미가 있는 조회에 쓴다 + /// (위치 재조회, 키워드 검색). + case replaceExisting + /// 기존 작업이 끝날 때까지 새 요청을 무시한다 — 중복 발사가 해로운 동작에 쓴다 + /// (당김 새로고침, 제출). + case skipIfRunning + /// 같은 키라도 독립적으로 돌린다 — 저장처럼 "나중 것이 앞 것을 취소하면 안 되는" + /// 작업에 쓴다. 키는 추적·일괄 취소 목적으로만 쓰인다. + case independent + } + + private struct Storage { + var tasks: [Key: Task] = [:] + /// `.independent`로 띄운 작업들 — 키로 덮어쓰지 않고 따로 모은다. + var detached: [Task] = [] + } + + private let storage = Mutex(Storage()) + + public init() {} + + deinit { + cancelAll() + } + + /// 정책에 따라 작업을 시작한다. 시작하지 않은 경우 `false`를 돌려준다. + @discardableResult + public func run( + _ key: Key, + _ policy: Policy = .replaceExisting, + _ body: @escaping @Sendable () async -> Void + ) -> Bool { + let shouldStart = storage.withLock { storage -> Bool in + switch policy { + case .skipIfRunning: + guard let existing = storage.tasks[key], !existing.isCancelled else { return true } + return false + case .replaceExisting: + storage.tasks[key]?.cancel() + return true + case .independent: + return true + } + } + guard shouldStart else { return false } + + let task = Task { [weak self] in + await body() + // 완료된 작업을 남겨 두면 `.skipIfRunning`이 영구히 막힌다 — + // 이 정리를 잊는 것이 기존 코드에서 실제로 났던 버그다(pull-to-refresh 영구 잠김). + self?.finish(key, policy: policy) + } + + storage.withLock { storage in + if case .independent = policy { + storage.detached.append(task) + } else { + storage.tasks[key] = task + } + } + return true + } + + public func isRunning(_ key: Key) -> Bool { + storage.withLock { storage in + guard let task = storage.tasks[key] else { return false } + return !task.isCancelled + } + } + + public func cancel(_ key: Key) { + storage.withLock { $0.tasks.removeValue(forKey: key) }?.cancel() + } + + /// 소유자 해제·로그아웃 등에서 부른다. `deinit`에서도 안전하다. + public func cancelAll() { + let (tasks, detached) = storage.withLock { storage -> ([Task], [Task]) in + let result = (Array(storage.tasks.values), storage.detached) + storage.tasks.removeAll() + storage.detached.removeAll() + return result + } + for task in tasks { task.cancel() } + for task in detached { task.cancel() } + } + + private func finish(_ key: Key, policy: Policy) { + storage.withLock { storage in + if case .independent = policy { + storage.detached.removeAll { $0.isCancelled } + } else { + storage.tasks[key] = nil + } + } + } +} diff --git a/Projects/Core/Concurrency/Tests/TaskBoxTests.swift b/Projects/Core/Concurrency/Tests/TaskBoxTests.swift new file mode 100644 index 00000000..578e7bb8 --- /dev/null +++ b/Projects/Core/Concurrency/Tests/TaskBoxTests.swift @@ -0,0 +1,146 @@ +@testable import CoreConcurrency +import Foundation +import Synchronization +import Testing + +private nonisolated final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + func increment() { lock.lock(); value += 1; lock.unlock() } + func get() -> Int { lock.lock(); defer { lock.unlock() }; return value } +} + +/// 모듈 기본 격리가 `.mainActor`라, 명시하지 않으면 `Hashable` 적합성까지 메인 액터 +/// 격리를 얻어 `TaskBox`의 `Sendable` 제약을 만족하지 못한다. +private nonisolated enum Key: Hashable, Sendable { case load, save, refresh } + +struct TaskBoxTests { + + // MARK: - replaceExisting + + /// 최신 입력만 의미가 있는 조회 — 새 요청이 이전 것을 취소한다. + @Test + func replaceExisting_cancelsPrevious() async { + let box = TaskBox() + let firstCancelled = Counter() + + box.run(.load, .replaceExisting) { + try? await Task.sleep(for: .milliseconds(200)) + if Task.isCancelled { firstCancelled.increment() } + } + box.run(.load, .replaceExisting) {} + + try? await Task.sleep(for: .milliseconds(50)) + #expect(firstCancelled.get() == 1) + } + + // MARK: - skipIfRunning + + /// 중복 발사가 해로운 동작 — 진행 중이면 새 요청을 무시한다. + @Test + func skipIfRunning_ignoresWhileRunning() async { + let box = TaskBox() + let started = Counter() + + box.run(.refresh, .skipIfRunning) { + started.increment() + try? await Task.sleep(for: .milliseconds(100)) + } + // Task는 생성 즉시 실행되지 않는다 — 첫 작업이 시작될 때까지 양보한다. + while started.get() == 0 { await Task.yield() } + + let secondStarted = box.run(.refresh, .skipIfRunning) { started.increment() } + + #expect(secondStarted == false) + #expect(started.get() == 1) + } + + /// 회귀: 완료된 작업을 정리하지 않으면 `.skipIfRunning`이 **영구히 막힌다.** + /// 기존 코드에서 실제로 났던 버그다(당김 새로고침이 취소 후 영원히 잠김). + @Test + func skipIfRunning_allowsNewRunAfterCompletion() async { + let box = TaskBox() + let started = Counter() + + box.run(.refresh, .skipIfRunning) { started.increment() } + while box.isRunning(.refresh) { await Task.yield() } + + let secondStarted = box.run(.refresh, .skipIfRunning) { started.increment() } + while box.isRunning(.refresh) { await Task.yield() } + + #expect(secondStarted == true) + #expect(started.get() == 2) + } + + /// 취소된 작업도 키를 붙잡고 있으면 안 된다. + @Test + func skipIfRunning_allowsNewRunAfterCancel() async { + let box = TaskBox() + box.run(.refresh, .skipIfRunning) { try? await Task.sleep(for: .seconds(10)) } + + box.cancel(.refresh) + + #expect(box.run(.refresh, .skipIfRunning) {} == true) + } + + // MARK: - independent + + /// 저장은 나중 것이 앞 것을 취소하면 안 된다 — 둘 다 끝까지 간다. + @Test + func independent_runsConcurrentlyWithoutCancelling() async { + let box = TaskBox() + let completed = Counter() + + for _ in 0..<3 { + box.run(.save, .independent) { + try? await Task.sleep(for: .milliseconds(20)) + guard !Task.isCancelled else { return } + completed.increment() + } + } + try? await Task.sleep(for: .milliseconds(120)) + + #expect(completed.get() == 3) + } + + // MARK: - 정리 + + @Test + func cancelAll_stopsEverything() async { + let box = TaskBox() + let finished = Counter() + + // `try?`가 취소를 삼키므로 취소 여부를 명시적으로 확인해야 한다 — + // 그러지 않으면 sleep 이후 코드가 그대로 실행된다. + box.run(.load) { + try? await Task.sleep(for: .seconds(10)) + guard !Task.isCancelled else { return } + finished.increment() + } + box.run(.save, .independent) { + try? await Task.sleep(for: .seconds(10)) + guard !Task.isCancelled else { return } + finished.increment() + } + while !box.isRunning(.load) { await Task.yield() } + + box.cancelAll() + try? await Task.sleep(for: .milliseconds(50)) + + #expect(finished.get() == 0) + #expect(box.isRunning(.load) == false) + } + + @Test + func cancel_affectsOnlyGivenKey() async { + let box = TaskBox() + box.run(.load) { try? await Task.sleep(for: .seconds(10)) } + box.run(.refresh) { try? await Task.sleep(for: .seconds(10)) } + + box.cancel(.load) + + #expect(box.isRunning(.load) == false) + #expect(box.isRunning(.refresh) == true) + box.cancelAll() + } +} diff --git a/Projects/Feature/Search/Sources/SearchViewController.swift b/Projects/Feature/Search/Sources/SearchViewController.swift index 8f7468a1..e15c33be 100644 --- a/Projects/Feature/Search/Sources/SearchViewController.swift +++ b/Projects/Feature/Search/Sources/SearchViewController.swift @@ -178,17 +178,26 @@ final class SearchViewController: UIViewController { viewModel.onStateChange = { [weak self] state in self?.render(state) } - viewModel.onFieldsChange = { [weak self] fields in - self?.renderFields(fields) - } + emptyState.onAction = { [weak self] in self?.viewModel.didTapEmptyAction() } render(viewModel.state) - renderFields(viewModel.fields) } + /// 직전 상태 — 바뀐 것만 반영하기 위해 VC가 들고 있다. 특히 입력 슬롯은 매번 + /// setText하면 `UITextField` 커서가 튀므로 값이 실제로 달라졌을 때만 쓴다. + private var rendered: SearchViewModel.State? + private func render(_ state: SearchViewModel.State) { + defer { rendered = state } + if rendered?.fields != state.fields { + renderFields(state.fields) + } + renderContent(state.content) + } + + private func renderContent(_ state: SearchViewModel.Content) { activityIndicator.stopAnimating() emptyState.isHidden = true showsRecentHeader = false diff --git a/Projects/Feature/Search/Sources/SearchViewModel.swift b/Projects/Feature/Search/Sources/SearchViewModel.swift index 60b7da3d..2f35fd78 100644 --- a/Projects/Feature/Search/Sources/SearchViewModel.swift +++ b/Projects/Feature/Search/Sources/SearchViewModel.swift @@ -12,7 +12,7 @@ final class SearchViewModel { /// 콘텐츠 영역(입력 필드 아래)의 표시 상태. /// 불변식: `.loadingRoutes`/`.routes`는 두 슬롯이 모두 확정일 때만 존재한다. - enum State: Equatable { + enum Content: Equatable { case idle case recent([PlaceViewData]) case places([PlaceViewData]) @@ -32,23 +32,27 @@ final class SearchViewModel { var activeField: Field } + /// 화면 상태 전부. 입력 슬롯을 밖에 두고 별도 콜백으로 흘리면 상태 채널이 둘이 + /// 되어, "지금 화면이 무엇을 보여 주는가"를 한 타입으로 말할 수 없다. + /// + /// 슬롯을 따로 흘리던 이유(매 키 입력마다 전체를 push하면 `UITextField` 커서가 + /// 튄다)는 정당하지만, **diff는 계약이 아니라 VC의 일이다** — VC가 직전 상태를 + /// 들고 바뀐 것만 반영한다. + struct State: Equatable { + var fields: FieldsViewData + var content: Content = .idle + } + /// Set by the ViewController; always invoked on the main actor. var onStateChange: ((State) -> Void)? - var onFieldsChange: ((FieldsViewData) -> Void)? /// Set by the Coordinator. Place = 확정 도착지 — 홈 도착지 필드의 원천이다(Phase 17). var onRouteChosen: ((LastRoute, Place) -> Void)? var onBackRequested: (() -> Void)? - private(set) var state: State = .idle { - didSet { onStateChange?(state) } - } - - private(set) var fields = FieldsViewData( - departureText: "", - arrivalText: "", - activeField: .departure + private(set) var state = State( + fields: FieldsViewData(departureText: "", arrivalText: "", activeField: .departure) ) { - didSet { if fields != oldValue { onFieldsChange?(fields) } } + didSet { if state != oldValue { onStateChange?(state) } } } // 확정된 슬롯. 타이핑이 시작되면 해당 슬롯은 다시 미확정으로 돌아간다. @@ -94,7 +98,7 @@ final class SearchViewModel { self.now = now self.debounceInterval = debounceInterval // 탭한 필드로 진입한다(Phase 17) — 초기 활성 슬롯만 정하고 프리필 정책은 불변. - fields.activeField = (initialField == .arrival) ? .arrival : .departure + state.fields.activeField = (initialField == .arrival) ? .arrival : .departure } deinit { @@ -113,9 +117,9 @@ final class SearchViewModel { } func fieldDidBeginEditing(_ field: Field) { - fields.activeField = field + state.fields.activeField = field let isConfirmed = (field == .departure) ? departure != nil : arrival != nil - switch state { + switch state.content { case .recent, .places: break default: @@ -129,13 +133,13 @@ final class SearchViewModel { case .departure: departure = nil case .arrival: arrival = nil } - var newFields = fields + var newFields = state.fields newFields.activeField = field switch field { case .departure: newFields.departureText = keyword case .arrival: newFields.arrivalText = keyword } - fields = newFields + state.fields = newFields searchTask?.cancel() routeTask?.cancel() @@ -153,33 +157,33 @@ final class SearchViewModel { try? await Task.sleep(for: interval) guard !Task.isCancelled else { return } // 디바운스 통과 = 이 키워드로 실제 요청한다 — 로딩은 여기서부터(Phase 17). - self?.state = .loadingPlaces + self?.state.content = .loadingPlaces do { // 현재 위치가 확보된 경우에만 근처 우선 정렬 바이어스를 건다. let places = try await useCase.execute(keyword: trimmed, near: self?.currentCoordinate) guard !Task.isCancelled else { return } self?.listedPlaces = places - self?.state = .places(places.map(PlaceViewData.init(entity:))) + self?.state.content = .places(places.map(PlaceViewData.init(entity:))) } catch { guard !Task.isCancelled else { return } - self?.state = .failed(message: "검색에 실패했어요") + self?.state.content = .failed(message: "검색에 실패했어요") } } } func didSelectListItem(at index: Int) { - switch state { + switch state.content { case .recent, .places: break default: return } guard listedPlaces.indices.contains(index) else { return } - confirm(listedPlaces[index], in: fields.activeField) + confirm(listedPlaces[index], in: state.fields.activeField) } func didDeleteRecent(at index: Int) { - guard case .recent = state, listedPlaces.indices.contains(index) else { return } + guard case .recent = state.content, listedPlaces.indices.contains(index) else { return } let place = listedPlaces[index] recentTask?.cancel() recentTask = Task { [weak self] in @@ -188,37 +192,37 @@ final class SearchViewModel { let places = (try? await useCase.fetch()) ?? [] guard !Task.isCancelled else { return } self?.listedPlaces = places - self?.state = .recent(places.map(PlaceViewData.init(entity:))) + self?.state.content = .recent(places.map(PlaceViewData.init(entity:))) } } func didTapMore() { - guard case .routes = state, !availableRoutes.isEmpty else { return } + guard case .routes = state.content, !availableRoutes.isEmpty else { return } isExpanded.toggle() - state = .routes( + state.content = .routes( RouteResultsViewData(entities: availableRoutes, isExpanded: isExpanded, now: now()) ) } func didSelectRoute(at index: Int) { - guard case .routes = state, availableRoutes.indices.contains(index) else { return } + guard case .routes = state.content, availableRoutes.indices.contains(index) else { return } // 불변식: routes 상태는 두 슬롯 확정 시에만 존재한다 — arrival은 항상 있다(방어 가드). guard let arrival else { return } onRouteChosen?(availableRoutes[index], arrival) } func didTapEmptyAction() { - switch state { + switch state.content { case .failed where departure != nil && arrival != nil: searchRoutes() case .serviceEnded, .noRoute: // "다시 검색하기" 실동작(Phase 17) — 도착지 슬롯을 비우고 포커스를 넘겨 // 즉시 재검색이 가능하게 한다. 출발지는 유지(대개 현재 위치). arrival = nil - var newFields = fields + var newFields = state.fields newFields.arrivalText = "" newFields.activeField = .arrival - fields = newFields + state.fields = newFields showRecent() case .failed: showRecent() @@ -241,12 +245,12 @@ final class SearchViewModel { guard !Task.isCancelled, let self else { return } self.currentCoordinate = coordinate // 사용자가 이미 출발지를 만졌다면 덮어쓰지 않는다. - guard self.departure == nil, self.fields.departureText.isEmpty else { return } + guard self.departure == nil, self.state.fields.departureText.isEmpty else { return } self.departure = Place(name: "현재 위치", address: "", coordinate: coordinate) - var newFields = self.fields + var newFields = self.state.fields newFields.departureText = "현재 위치" newFields.activeField = .arrival - self.fields = newFields + self.state.fields = newFields // 위치가 늦게 도착해 도착지가 먼저 확정된 경우를 마감한다. if self.arrival != nil { self.searchRoutes() } } @@ -259,7 +263,7 @@ final class SearchViewModel { try? await useCase.save(place) } - var newFields = fields + var newFields = state.fields switch field { case .departure: departure = place @@ -270,11 +274,11 @@ final class SearchViewModel { } if departure != nil, arrival != nil { - fields = newFields + state.fields = newFields searchRoutes() } else { newFields.activeField = (field == .departure) ? .arrival : .departure - fields = newFields + state.fields = newFields showRecent() } } @@ -284,7 +288,7 @@ final class SearchViewModel { searchTask?.cancel() recentTask?.cancel() routeTask?.cancel() - state = .loadingRoutes + state.content = .loadingRoutes routeTask = Task { [weak self] in guard let useCase = self?.searchLastRoutesUseCase else { return } do { @@ -295,20 +299,20 @@ final class SearchViewModel { guard let self else { return } self.availableRoutes = routes self.isExpanded = false - self.state = .routes( + self.state.content = .routes( RouteResultsViewData(entities: routes, isExpanded: false, now: self.now()) ) case .available: // 정규화가 놓친 빈 목록 방어. - self?.state = .noRoute + self?.state.content = .noRoute case .serviceEnded: - self?.state = .serviceEnded + self?.state.content = .serviceEnded case .noRoute: - self?.state = .noRoute + self?.state.content = .noRoute } } catch { guard !Task.isCancelled else { return } - self?.state = .failed(message: "막차를 찾지 못했어요") + self?.state.content = .failed(message: "막차를 찾지 못했어요") } } } @@ -321,7 +325,7 @@ final class SearchViewModel { let places = (try? await useCase.fetch()) ?? [] guard !Task.isCancelled else { return } self?.listedPlaces = places - self?.state = .recent(places.map(PlaceViewData.init(entity:))) + self?.state.content = .recent(places.map(PlaceViewData.init(entity:))) } } } diff --git a/Projects/Feature/Search/Tests/SearchViewModelTests.swift b/Projects/Feature/Search/Tests/SearchViewModelTests.swift index 240b0a34..df336fb0 100644 --- a/Projects/Feature/Search/Tests/SearchViewModelTests.swift +++ b/Projects/Feature/Search/Tests/SearchViewModelTests.swift @@ -153,13 +153,13 @@ private func makeSUT( @MainActor private func driveBothSlotsConfirmed(sut: SearchViewModel, recorder: StateRecorder) async { sut.viewDidLoad() - await recorder.waitUntilLast { if case .recent = $0 { true } else { false } } + await recorder.waitUntilLast { if case .recent = $0.content { true } else { false } } sut.keywordDidChange("강남", in: .departure) - await recorder.waitUntilLast { if case .places = $0 { true } else { false } } + await recorder.waitUntilLast { if case .places = $0.content { true } else { false } } sut.didSelectListItem(at: 0) - await recorder.waitUntilLast { if case .recent = $0 { true } else { false } } + await recorder.waitUntilLast { if case .recent = $0.content { true } else { false } } sut.keywordDidChange("회사", in: .arrival) - await recorder.waitUntilLast { if case .places = $0 { true } else { false } } + await recorder.waitUntilLast { if case .places = $0.content { true } else { false } } sut.didSelectListItem(at: 0) } @@ -175,9 +175,9 @@ struct SearchViewModelTests { recorder.attach(to: sut) sut.viewDidLoad() - await recorder.waitUntilLast { if case .recent = $0 { true } else { false } } + await recorder.waitUntilLast { if case .recent = $0.content { true } else { false } } - #expect(recorder.states.last == .recent(recents.map(PlaceViewData.init(entity:)))) + #expect(recorder.states.last?.content == .recent(recents.map(PlaceViewData.init(entity:)))) } @Test @@ -197,10 +197,10 @@ struct SearchViewModelTests { sut.keywordDidChange("강", in: .departure) await Task.yield() sut.keywordDidChange("강남", in: .departure) - await recorder.waitUntilLast { $0 == .places([PlaceViewData(entity: fresh)]) } + await recorder.waitUntilLast { $0.content == .places([PlaceViewData(entity: fresh)]) } - #expect(!recorder.states.contains(.places([PlaceViewData(entity: stale)]))) - #expect(!recorder.states.contains { if case .failed = $0 { true } else { false } }) + #expect(!recorder.states.contains { $0.content == .places([PlaceViewData(entity: stale)]) }) + #expect(!recorder.states.contains { if case .failed = $0.content { true } else { false } }) } @Test @@ -215,11 +215,11 @@ struct SearchViewModelTests { recorder.attach(to: sut) await driveBothSlotsConfirmed(sut: sut, recorder: recorder) - await recorder.waitUntilLast { if case .routes = $0 { true } else { false } } + await recorder.waitUntilLast { if case .routes = $0.content { true } else { false } } - #expect(recorder.states.contains(.loadingRoutes)) + #expect(recorder.states.contains { $0.content == .loadingRoutes }) let expected = RouteResultsViewData(entities: routes, isExpanded: false, now: fixedNow) - #expect(recorder.states.last == .routes(expected)) + #expect(recorder.states.last?.content == .routes(expected)) #expect(expected.featured.badgeText == "가장 늦은 차") #expect(expected.alternatives.count == 1) } @@ -234,9 +234,9 @@ struct SearchViewModelTests { recorder.attach(to: sut) await driveBothSlotsConfirmed(sut: sut, recorder: recorder) - await recorder.waitUntilLast { $0 == .serviceEnded } + await recorder.waitUntilLast { $0.content == .serviceEnded } - #expect(recorder.states.last == .serviceEnded) + #expect(recorder.states.last?.content == .serviceEnded) } @Test @@ -249,9 +249,9 @@ struct SearchViewModelTests { recorder.attach(to: sut) await driveBothSlotsConfirmed(sut: sut, recorder: recorder) - await recorder.waitUntilLast { $0 == .noRoute } + await recorder.waitUntilLast { $0.content == .noRoute } - #expect(recorder.states.last == .noRoute) + #expect(recorder.states.last?.content == .noRoute) } @Test @@ -261,9 +261,9 @@ struct SearchViewModelTests { recorder.attach(to: sut) sut.keywordDidChange("강남", in: .departure) - await recorder.waitUntilLast { if case .failed = $0 { true } else { false } } + await recorder.waitUntilLast { if case .failed = $0.content { true } else { false } } - #expect(recorder.states.last == .failed(message: "검색에 실패했어요")) + #expect(recorder.states.last?.content == .failed(message: "검색에 실패했어요")) } @Test @@ -276,9 +276,9 @@ struct SearchViewModelTests { recorder.attach(to: sut) await driveBothSlotsConfirmed(sut: sut, recorder: recorder) - await recorder.waitUntilLast { if case .failed = $0 { true } else { false } } + await recorder.waitUntilLast { if case .failed = $0.content { true } else { false } } - #expect(recorder.states.last == .failed(message: "막차를 찾지 못했어요")) + #expect(recorder.states.last?.content == .failed(message: "막차를 찾지 못했어요")) } @Test @@ -291,13 +291,13 @@ struct SearchViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) await driveBothSlotsConfirmed(sut: sut, recorder: recorder) - await recorder.waitUntilLast { if case .routes = $0 { true } else { false } } + await recorder.waitUntilLast { if case .routes = $0.content { true } else { false } } sut.didTapMore() - #expect(recorder.states.last == .routes(RouteResultsViewData(entities: routes, isExpanded: true, now: fixedNow))) + #expect(recorder.states.last?.content == .routes(RouteResultsViewData(entities: routes, isExpanded: true, now: fixedNow))) sut.didTapMore() - #expect(recorder.states.last == .routes(RouteResultsViewData(entities: routes, isExpanded: false, now: fixedNow))) + #expect(recorder.states.last?.content == .routes(RouteResultsViewData(entities: routes, isExpanded: false, now: fixedNow))) } @Test @@ -309,13 +309,13 @@ struct SearchViewModelTests { recorder.attach(to: sut) sut.keywordDidChange("강남", in: .departure) - await recorder.waitUntilLast { if case .places = $0 { true } else { false } } + await recorder.waitUntilLast { if case .places = $0.content { true } else { false } } sut.didSelectListItem(at: 0) - await recorder.waitUntilLast { if case .recent = $0 { true } else { false } } + await recorder.waitUntilLast { if case .recent = $0.content { true } else { false } } #expect(await store.saved == [place]) - #expect(sut.fields.departureText == "강남역") - #expect(sut.fields.activeField == .arrival) + #expect(sut.state.fields.departureText == "강남역") + #expect(sut.state.fields.activeField == .arrival) } @Test @@ -328,9 +328,9 @@ struct SearchViewModelTests { recorder.attach(to: sut) sut.viewDidLoad() - await recorder.waitUntilLast { if case .recent = $0 { true } else { false } } + await recorder.waitUntilLast { if case .recent = $0.content { true } else { false } } sut.didDeleteRecent(at: 0) - await recorder.waitUntilLast { $0 == .recent([PlaceViewData(entity: second)]) } + await recorder.waitUntilLast { $0.content == .recent([PlaceViewData(entity: second)]) } #expect(await store.removed == [first]) } @@ -341,10 +341,10 @@ struct SearchViewModelTests { let sut = makeSUT(location: { coordinate }) sut.viewDidLoad() - while sut.fields.departureText.isEmpty { await Task.yield() } + while sut.state.fields.departureText.isEmpty { await Task.yield() } - #expect(sut.fields.departureText == "현재 위치") - #expect(sut.fields.activeField == .arrival) + #expect(sut.state.fields.departureText == "현재 위치") + #expect(sut.state.fields.activeField == .arrival) } @Test @@ -354,11 +354,11 @@ struct SearchViewModelTests { recorder.attach(to: sut) sut.viewDidLoad() - await recorder.waitUntilLast { if case .recent = $0 { true } else { false } } + await recorder.waitUntilLast { if case .recent = $0.content { true } else { false } } for _ in 0..<20 { await Task.yield() } - #expect(sut.fields.departureText.isEmpty) - #expect(sut.fields.activeField == .departure) + #expect(sut.state.fields.departureText.isEmpty) + #expect(sut.state.fields.activeField == .departure) } @Test @@ -376,13 +376,13 @@ struct SearchViewModelTests { sut.viewDidLoad() sut.keywordDidChange("강남", in: .departure) - await recorder.waitUntilLast { if case .places = $0 { true } else { false } } + await recorder.waitUntilLast { if case .places = $0.content { true } else { false } } continuation.yield(Coordinate(latitude: 37.49, longitude: 127.02)) continuation.finish() for _ in 0..<20 { await Task.yield() } - #expect(sut.fields.departureText == "강남") + #expect(sut.state.fields.departureText == "강남") } @Test @@ -400,9 +400,9 @@ struct SearchViewModelTests { recorder.attach(to: sut) sut.viewDidLoad() - while sut.fields.departureText.isEmpty { await Task.yield() } + while sut.state.fields.departureText.isEmpty { await Task.yield() } sut.keywordDidChange("회사", in: .arrival) - await recorder.waitUntilLast { if case .places = $0 { true } else { false } } + await recorder.waitUntilLast { if case .places = $0.content { true } else { false } } #expect(await log.coordinates == [coordinate]) } @@ -419,13 +419,13 @@ struct SearchViewModelTests { recorder.attach(to: sut) sut.viewDidLoad() - while sut.fields.departureText.isEmpty { await Task.yield() } + while sut.state.fields.departureText.isEmpty { await Task.yield() } sut.keywordDidChange("회사", in: .arrival) - await recorder.waitUntilLast { if case .places = $0 { true } else { false } } + await recorder.waitUntilLast { if case .places = $0.content { true } else { false } } sut.didSelectListItem(at: 0) - await recorder.waitUntilLast { if case .routes = $0 { true } else { false } } + await recorder.waitUntilLast { if case .routes = $0.content { true } else { false } } - #expect(recorder.states.last == .routes(RouteResultsViewData(entities: routes, isExpanded: false, now: fixedNow))) + #expect(recorder.states.last?.content == .routes(RouteResultsViewData(entities: routes, isExpanded: false, now: fixedNow))) } @Test @@ -446,7 +446,7 @@ struct SearchViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) await driveBothSlotsConfirmed(sut: sut, recorder: recorder) - await recorder.waitUntilLast { if case .routes = $0 { true } else { false } } + await recorder.waitUntilLast { if case .routes = $0.content { true } else { false } } sut.didSelectRoute(at: 0) @@ -459,10 +459,10 @@ struct SearchViewModelTests { @Test func initialFieldArrival_startsWithArrivalSlotActive() { let sut = makeSUT(initialField: .arrival) - #expect(sut.fields.activeField == .arrival) + #expect(sut.state.fields.activeField == .arrival) let departureEntry = makeSUT(initialField: .departure) - #expect(departureEntry.fields.activeField == .departure) + #expect(departureEntry.state.fields.activeField == .departure) } @Test @@ -473,10 +473,10 @@ struct SearchViewModelTests { recorder.attach(to: sut) sut.keywordDidChange("강남", in: .departure) - await recorder.waitUntilLast { if case .places = $0 { true } else { false } } + await recorder.waitUntilLast { if case .places = $0.content { true } else { false } } // 디바운스 통과 후 요청 직전의 로딩 상태를 반드시 거친다. - #expect(recorder.states.contains(.loadingPlaces)) + #expect(recorder.states.contains { $0.content == .loadingPlaces }) } @Test @@ -486,10 +486,10 @@ struct SearchViewModelTests { recorder.attach(to: sut) sut.keywordDidChange("결과없는키워드", in: .arrival) - await recorder.waitUntilLast { $0 == .places([]) } + await recorder.waitUntilLast { $0.content == .places([]) } // 0건도 .places로 흐른다 — VC가 빈 상태("검색 결과가 없어요")를 그린다. - #expect(recorder.states.last == .places([])) + #expect(recorder.states.last?.content == .places([])) } @Test @@ -502,16 +502,16 @@ struct SearchViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) await driveBothSlotsConfirmed(sut: sut, recorder: recorder) - await recorder.waitUntilLast { $0 == .serviceEnded } - #expect(!sut.fields.arrivalText.isEmpty) + await recorder.waitUntilLast { $0.content == .serviceEnded } + #expect(!sut.state.fields.arrivalText.isEmpty) sut.didTapEmptyAction() - await recorder.waitUntilLast { if case .recent = $0 { true } else { false } } + await recorder.waitUntilLast { if case .recent = $0.content { true } else { false } } - #expect(sut.fields.arrivalText.isEmpty) - #expect(sut.fields.activeField == .arrival) + #expect(sut.state.fields.arrivalText.isEmpty) + #expect(sut.state.fields.activeField == .arrival) // 출발지는 유지 — 즉시 새 도착지 검색이 가능하다. - #expect(sut.fields.departureText == "강남역") + #expect(sut.state.fields.departureText == "강남역") } @Test @@ -523,13 +523,13 @@ struct SearchViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) await driveBothSlotsConfirmed(sut: sut, recorder: recorder) - await recorder.waitUntilLast { $0 == .noRoute } + await recorder.waitUntilLast { $0.content == .noRoute } sut.didTapEmptyAction() - await recorder.waitUntilLast { if case .recent = $0 { true } else { false } } + await recorder.waitUntilLast { if case .recent = $0.content { true } else { false } } - #expect(sut.fields.arrivalText.isEmpty) - #expect(sut.fields.activeField == .arrival) + #expect(sut.state.fields.arrivalText.isEmpty) + #expect(sut.state.fields.activeField == .arrival) } @Test diff --git a/Projects/Feature/Settings/Sources/HomeAddressViewController.swift b/Projects/Feature/Settings/Sources/HomeAddressViewController.swift index c8530c2a..86079149 100644 --- a/Projects/Feature/Settings/Sources/HomeAddressViewController.swift +++ b/Projects/Feature/Settings/Sources/HomeAddressViewController.swift @@ -77,15 +77,6 @@ final class HomeAddressViewController: SettingsScreen { viewModel.onStateChange = { [weak self] state in self?.render(state) } - viewModel.onSavingChange = { [weak self] isSaving in - self?.currentLocationButton.isEnabled = !isSaving - self?.tableView.isUserInteractionEnabled = !isSaving - if isSaving { - self?.activityIndicator.startAnimating() - } else { - self?.activityIndicator.stopAnimating() - } - } viewModel.onToast = { [weak self] message in self?.showToast(message) } @@ -93,9 +84,13 @@ final class HomeAddressViewController: SettingsScreen { } private func render(_ state: HomeAddressViewModel.State) { + // 저장 중에는 입력을 막는다 — 이전에는 별도 콜백이 하던 일이다. + currentLocationButton.isEnabled = !state.isSaving + tableView.isUserInteractionEnabled = !state.isSaving + activityIndicator.stopAnimating() emptyState.isHidden = true - switch state { + switch state.content { case .idle: places = [] case .loading: @@ -112,6 +107,8 @@ final class HomeAddressViewController: SettingsScreen { emptyState.configure(with: .init(title: message)) emptyState.isHidden = false } + // 저장 스피너는 콘텐츠 상태와 독립이다 — 목록이 무엇이든 저장 중이면 돈다. + if state.isSaving { activityIndicator.startAnimating() } tableView.reloadData() } } diff --git a/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift b/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift index 98509d2c..5136246a 100644 --- a/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift +++ b/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift @@ -9,7 +9,8 @@ final class HomeAddressViewModel { let address: String } - enum State: Equatable { + /// 목록 영역이 보여 주는 것 — 배타적이라 enum이다. + enum Content: Equatable { case idle case loading case places([PlaceViewData]) @@ -17,21 +18,23 @@ final class HomeAddressViewModel { case failed(message: String) } + /// 화면 상태 전부. 저장 진행 여부를 밖에 두고 별도 콜백으로 흘리면 상태 채널이 + /// 둘이 되어, "무엇이 화면에 보이는가"를 한 타입으로 말할 수 없다. + struct State: Equatable { + var content: Content = .idle + var isSaving = false + } + /// Set by the ViewController; always invoked on the main actor. var onStateChange: ((State) -> Void)? - var onSavingChange: ((Bool) -> Void)? var onToast: ((String) -> Void)? /// Set by the Coordinator — 저장 성공 시 1회. var onSaved: (() -> Void)? - private(set) var state: State = .idle { + private(set) var state = State() { didSet { if state != oldValue { onStateChange?(state) } } } - private(set) var isSaving = false { - didSet { if isSaving != oldValue { onSavingChange?(isSaving) } } - } - private var listedPlaces: [Place] = [] private let searchPlacesUseCase: any SearchPlacesUseCase private let getCurrentLocationUseCase: any GetCurrentLocationUseCase @@ -66,7 +69,7 @@ final class HomeAddressViewModel { let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { listedPlaces = [] - state = .idle + state.content = .idle return } // [weak self]: the in-flight task must not keep the ViewModel alive. @@ -75,17 +78,17 @@ final class HomeAddressViewModel { let useCase = self?.searchPlacesUseCase else { return } try? await Task.sleep(for: interval) guard !Task.isCancelled else { return } - self?.state = .loading + self?.state.content = .loading do { let places = try await useCase.execute(keyword: trimmed, near: nil) guard !Task.isCancelled else { return } self?.listedPlaces = places - self?.state = places.isEmpty + self?.state.content = places.isEmpty ? .empty : .places(places.map { PlaceViewData(name: $0.name, address: $0.address) }) } catch { guard !Task.isCancelled else { return } - self?.state = .failed(message: "검색에 실패했어요") + self?.state.content = .failed(message: "검색에 실패했어요") } } } @@ -97,8 +100,8 @@ final class HomeAddressViewModel { /// 현재 위치 → 역지오코딩 주소로 저장(레거시 "현위치 찾기"). func useCurrentLocationTapped() { - guard !isSaving else { return } - isSaving = true + guard !state.isSaving else { return } + state.isSaving = true saveTask = Task { [weak self] in guard let locate = self?.getCurrentLocationUseCase, let geocode = self?.reverseGeocodeUseCase else { return } @@ -106,19 +109,19 @@ final class HomeAddressViewModel { let coordinate = try await locate.execute() let place = try await geocode.execute(coordinate: coordinate) guard !Task.isCancelled else { return } - self?.isSaving = false + self?.state.isSaving = false self?.save(place) } catch { guard !Task.isCancelled else { return } - self?.isSaving = false + self?.state.isSaving = false self?.onToast?("현재 위치를 확인하지 못했어요. 잠시 후 다시 시도해 주세요") } } } private func save(_ place: Place) { - guard !isSaving else { return } - isSaving = true + guard !state.isSaving else { return } + state.isSaving = true // 검색 결과는 도로명 주소가 비어 있을 수 있다 — 이름으로 대신한다. let address = place.address.isEmpty ? place.name : place.address saveTask = Task { [weak self] in @@ -126,15 +129,15 @@ final class HomeAddressViewModel { do { try await useCase.execute(address: address, coordinate: place.coordinate) guard !Task.isCancelled else { return } - self?.isSaving = false + self?.state.isSaving = false self?.onSaved?() } catch UpdateHomeAddressError.outOfServiceRegion { guard !Task.isCancelled else { return } - self?.isSaving = false + self?.state.isSaving = false self?.onToast?("앗차는 현재 서울, 경기, 인천에서만 이용 가능해요") } catch { guard !Task.isCancelled else { return } - self?.isSaving = false + self?.state.isSaving = false self?.onToast?("집 주소를 변경하지 못했어요. 다시 시도해 주세요") } } diff --git a/Projects/Feature/Settings/Sources/SettingsViewModel.swift b/Projects/Feature/Settings/Sources/SettingsViewModel.swift index 2e9553fd..373ea5d4 100644 --- a/Projects/Feature/Settings/Sources/SettingsViewModel.swift +++ b/Projects/Feature/Settings/Sources/SettingsViewModel.swift @@ -24,19 +24,28 @@ final class SettingsViewModel { let rows: [Row] } + /// 화면 상태 전부를 담는다 — State 밖에 상태를 두지 않는 것이 이 코드베이스의 + /// ViewModel 규약이다. 이전에는 `sections`(렌더 결과)가 상태 자리에 있고 실제 + /// 상태 셋이 private 필드로 흩어져 있어서, "로딩 중 + 업데이트 있음 + 로그아웃 중" + /// 같은 조합을 검증하려면 ViewModel을 통째로 조립해야 했다. + struct State: Equatable { + var addressText = "불러오는 중…" + var hasUpdate = false + var isLoggingOut = false + } + /// Set by the ViewController; always invoked on the main actor. var onSectionsChange: (([Section]) -> Void)? var onToast: ((String) -> Void)? /// Set by the Coordinator. var onRoute: ((Route) -> Void)? - private(set) var sections: [Section] = [] { - didSet { if sections != oldValue { onSectionsChange?(sections) } } + private(set) var state = State() { + didSet { if state != oldValue { onSectionsChange?(sections) } } } - private var addressText = "불러오는 중…" - private var hasUpdate = false - private var isLoggingOut = false + /// 상태에서 파생되는 렌더 모델 — 저장하지 않는다. + var sections: [Section] { Self.sections(from: state, currentVersion: currentVersion) } private let getUserProfileUseCase: any GetUserProfileUseCase private let logoutUseCase: any LogoutUseCase @@ -60,7 +69,6 @@ final class SettingsViewModel { self.checkAppUpdateUseCase = checkAppUpdateUseCase self.currentVersion = currentVersion self.appStoreURL = appStoreURL - rebuildSections() } deinit { @@ -96,8 +104,8 @@ final class SettingsViewModel { /// 로그인 화면 복귀는 세션 만료 관찰(App)이 한다 — 여기선 중복 탭만 막는다. func logoutConfirmed() { - guard !isLoggingOut else { return } - isLoggingOut = true + guard !state.isLoggingOut else { return } + state.isLoggingOut = true logoutTask = Task { [weak self] in guard let useCase = self?.logoutUseCase else { return } await useCase.execute() @@ -117,8 +125,7 @@ final class SettingsViewModel { text = "주소를 불러오지 못했어요" } guard !Task.isCancelled else { return } - self?.addressText = text - self?.rebuildSections() + self?.state.addressText = text } } @@ -129,18 +136,18 @@ final class SettingsViewModel { // 실패는 무음 — 버전 행은 현재 버전만 보여 준다. guard case .recommended = try? await checkAppUpdateUseCase.execute(currentVersion: currentVersion), !Task.isCancelled else { return } - self?.hasUpdate = true - self?.rebuildSections() + self?.state.hasUpdate = true } } - private func rebuildSections() { - sections = [ - Section(title: "내 정보", rows: [.homeAddress(subtitle: addressText)]), + /// 순수 함수 — 상태만 있으면 ViewModel 없이도 렌더 결과를 검증할 수 있다. + nonisolated static func sections(from state: State, currentVersion: String) -> [Section] { + [ + Section(title: "내 정보", rows: [.homeAddress(subtitle: state.addressText)]), Section(title: "앱 정보", rows: [ .privacyPolicy, .feedback, - .version(text: currentVersion, hasUpdate: hasUpdate), + .version(text: currentVersion, hasUpdate: state.hasUpdate), ]), Section(title: nil, rows: [.logout, .withdraw]), ] diff --git a/Projects/Feature/Settings/Sources/WithdrawViewModel.swift b/Projects/Feature/Settings/Sources/WithdrawViewModel.swift index 20545933..22131f9a 100644 --- a/Projects/Feature/Settings/Sources/WithdrawViewModel.swift +++ b/Projects/Feature/Settings/Sources/WithdrawViewModel.swift @@ -29,9 +29,24 @@ final class WithdrawViewModel { struct State: Equatable { var selected: Reason? - var isOtherInputVisible: Bool { selected == .other } - var canSubmit = false + /// 기타 사유 입력값. State 밖에 두면 "선택은 기타인데 입력이 비어 있다" 같은 + /// 조합이 상태 타입으로 표현되지 않아, 제출 가능 여부를 따로 계산해 저장하게 된다. + var otherText = "" var isSubmitting = false + + var isOtherInputVisible: Bool { selected == .other } + + /// 파생 — 저장하지 않는다. 저장하면 `selected`/`otherText`/`isSubmitting`이 + /// 바뀔 때마다 갱신을 잊지 않아야 하고, 그 자체가 불일치의 원인이 된다. + var canSubmit: Bool { !isSubmitting && reasonText != nil } + + /// 서버로 보낼 사유. 기타는 공백만 남으면 미선택과 같다. + var reasonText: String? { + guard let selected else { return nil } + guard selected == .other else { return selected.title } + let trimmed = otherText.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } } /// Set by the ViewController; always invoked on the main actor. @@ -42,7 +57,6 @@ final class WithdrawViewModel { didSet { if state != oldValue { onStateChange?(state) } } } - private var otherText = "" private let withdrawUseCase: any WithdrawUseCase private var submitTask: Task? @@ -56,19 +70,16 @@ final class WithdrawViewModel { func select(_ reason: Reason) { state.selected = reason - refreshCanSubmit() } func otherTextDidChange(_ text: String) { - otherText = text - refreshCanSubmit() + state.otherText = text } /// 확인 팝업을 통과한 뒤에만 호출된다. 성공하면 앱의 세션 만료 관찰이 로그인으로 보낸다. func withdrawConfirmed() { - guard state.canSubmit, !state.isSubmitting, let reason = reasonText() else { return } + guard state.canSubmit, let reason = state.reasonText else { return } state.isSubmitting = true - refreshCanSubmit() submitTask = Task { [weak self] in guard let useCase = self?.withdrawUseCase else { return } do { @@ -77,20 +88,9 @@ final class WithdrawViewModel { // 실패 시 세션이 보존된다(UseCase 계약) — 같은 화면에서 재시도할 수 있다. guard !Task.isCancelled else { return } self?.state.isSubmitting = false - self?.refreshCanSubmit() self?.onToast?("탈퇴에 실패했어요. 다시 시도해 주세요") } } } - private func reasonText() -> String? { - guard let selected = state.selected else { return nil } - guard selected == .other else { return selected.title } - let trimmed = otherText.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private func refreshCanSubmit() { - state.canSubmit = !state.isSubmitting && reasonText() != nil - } } diff --git a/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift b/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift index 0cde68ee..39cba0d7 100644 --- a/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift +++ b/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift @@ -30,7 +30,7 @@ struct HomeAddressViewModelTests { sut.onSaved = { savedCount += 1 } sut.keywordDidChange("시청") - await waitUntil { if case .places = sut.state { true } else { false } } + await waitUntil { if case .places = sut.state.content { true } else { false } } sut.didSelectPlace(at: 0) await waitUntil { savedCount == 1 } @@ -50,7 +50,7 @@ struct HomeAddressViewModelTests { await waitUntil { !toasts.isEmpty } #expect(toasts == ["앗차는 현재 서울, 경기, 인천에서만 이용 가능해요"]) - #expect(sut.isSaving == false) + #expect(sut.state.isSaving == false) } @Test diff --git a/Projects/Feature/Settings/Tests/SettingsSectionsTests.swift b/Projects/Feature/Settings/Tests/SettingsSectionsTests.swift new file mode 100644 index 00000000..38376bcf --- /dev/null +++ b/Projects/Feature/Settings/Tests/SettingsSectionsTests.swift @@ -0,0 +1,62 @@ +@testable import SettingsFeature +import Testing + +/// `sections`가 순수 함수가 되면서 가능해진 검증 — **ViewModel 인스턴스 없이** +/// 상태 조합만으로 렌더 결과를 확인한다. 이전에는 이 조합들을 보려면 UseCase 스텁을 +/// 조립하고 비동기 로딩이 끝나기를 기다려야 했다. +struct SettingsSectionsTests { + private func sections( + address: String = "서울 마포구", + hasUpdate: Bool = false, + isLoggingOut: Bool = false + ) -> [SettingsViewModel.Section] { + SettingsViewModel.sections( + from: .init( + addressText: address, hasUpdate: hasUpdate, isLoggingOut: isLoggingOut + ), + currentVersion: "1.2.3" + ) + } + + @Test + func alwaysHasThreeSections() { + #expect(sections().count == 3) + } + + @Test + func addressText_flowsIntoHomeAddressRow() { + let rows = sections(address: "집 주소를 등록해 주세요")[0].rows + #expect(rows == [.homeAddress(subtitle: "집 주소를 등록해 주세요")]) + } + + /// 로딩 실패 문구도 같은 자리에 그대로 실린다 — 별도 상태를 만들지 않는다. + @Test + func loadFailureText_usesSameRow() { + let rows = sections(address: "주소를 불러오지 못했어요")[0].rows + #expect(rows == [.homeAddress(subtitle: "주소를 불러오지 못했어요")]) + } + + @Test + func versionRow_carriesUpdateFlag() { + #expect(sections(hasUpdate: false)[1].rows.contains( + .version(text: "1.2.3", hasUpdate: false) + )) + #expect(sections(hasUpdate: true)[1].rows.contains( + .version(text: "1.2.3", hasUpdate: true) + )) + } + + /// 로그아웃 진행 상태는 행 구성을 바꾸지 않는다 — 중복 탭 방지용 내부 상태일 뿐이다. + /// 이 사실이 지금은 테스트로 고정돼 있다(이전에는 코드를 읽어야 알 수 있었다). + @Test + func loggingOut_doesNotChangeRows() { + #expect(sections(isLoggingOut: false) == sections(isLoggingOut: true)) + } + + @Test + func lastSection_hasNoTitleAndHoldsAccountActions() { + let last = sections()[2] + #expect(last.title == nil) + #expect(last.rows == [.logout, .withdraw]) + } +} diff --git a/Workspace.swift b/Workspace.swift index 15b97644..e78e10c4 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -13,6 +13,7 @@ let workspace = Workspace( "Projects/Core/Storage", "Projects/Core/Auth", "Projects/Core/Alarm", + "Projects/Core/Concurrency", "Projects/Core/Coordinator", "Projects/Core/LiveActivity", "Projects/DesignSystem",