diff --git a/.gitignore b/.gitignore index 980ef11..286b04c 100644 --- a/.gitignore +++ b/.gitignore @@ -89,4 +89,5 @@ fastlane/test_output iOSInjectionProject/ -.idea/ \ No newline at end of file +.idea/ +.DS_Store diff --git a/Shared/JSON/Cities.json b/Shared/Preview Content/TestCitiesData.swift similarity index 88% rename from Shared/JSON/Cities.json rename to Shared/Preview Content/TestCitiesData.swift index c067319..9ae76ce 100644 --- a/Shared/JSON/Cities.json +++ b/Shared/Preview Content/TestCitiesData.swift @@ -1,3 +1,7 @@ +import Foundation + +let testCities = try! JSONDecoder().decode([Find.City].self, from: testCitiesData) +let testCitiesData = """ [ { "id": 1809858, @@ -68,4 +72,5 @@ } ] } -] \ No newline at end of file +] +""".data(using: .utf8)! diff --git a/Shared/JSON/OneCall.json b/Shared/Preview Content/TestOneCallData.swift similarity index 99% rename from Shared/JSON/OneCall.json rename to Shared/Preview Content/TestOneCallData.swift index aa8f720..1d248a6 100644 --- a/Shared/JSON/OneCall.json +++ b/Shared/Preview Content/TestOneCallData.swift @@ -1,3 +1,7 @@ +import Foundation + +let testOneCall = try! JSONDecoder().decode(OneCall.self, from: testOneCallData) +let testOneCallData = """ { "daily": [ { @@ -1479,4 +1483,5 @@ "pop": 0.89000000000000001 } ] -} \ No newline at end of file +} +""".data(using: .utf8)! diff --git a/Shared/State/DataFlow/ForecastState.swift b/Shared/State/DataFlow/ForecastState.swift index 8e44e74..788c108 100644 --- a/Shared/State/DataFlow/ForecastState.swift +++ b/Shared/State/DataFlow/ForecastState.swift @@ -1,96 +1,99 @@ import Foundation import ComposableArchitecture -struct ForecastState: Equatable { - var followingList: [CityViewModel] = [] +struct ForecastReducer: ReducerProtocol { - var forecast: [Int: OneCall]? - var loadingCityIDSet: Set = [] -} - -enum ForecastAction: Equatable { - case fetchFollowingCity - case fetchFollowingCityDone(Result<[CityViewModel], AppError>) - - case follow(city: CityViewModel) - case followDone(Result) - case unfollowCity(indexSet: IndexSet) - case unfollowCityDone(Result) - case moveCity(indexSet: IndexSet, toIndex: Int) + struct State: Equatable { + var followingList: [CityViewModel] = [] + + var forecast: [Int: OneCall]? + var loadingCityIDSet: Set = [] + } - case loadCityForecast(city: CityViewModel) - case loadCityForecastDone(cityID: Int, result: Result) -} - -struct ForecastEnvironment { - var mainQueue: AnySchedulerOf - var weatherClient: WeatherClient - var followingClient: FollowingClient -} - -let forecastReducer = Reducer { - state, action, environment in + enum Action: Equatable { + case fetchFollowingCity + case fetchFollowingCityDone(TaskResult<[CityViewModel]>) + + case follow(city: CityViewModel) + case followDone(TaskResult) + case unfollowCity(indexSet: IndexSet) + case unfollowCityDone(TaskResult) + case moveCity(indexSet: IndexSet, toIndex: Int) + + case loadCityForecast(city: CityViewModel) + case loadCityForecastDone(cityID: Int, result: TaskResult) + } - switch action { - case .fetchFollowingCity: - return environment.followingClient - .fetch() - .receive(on: environment.mainQueue) - .catchToEffect(ForecastAction.fetchFollowingCityDone) - case .fetchFollowingCityDone(let result): - switch result { - case .success(let list): - state.followingList = list - case .failure(let error): - customDump(error) - } - return .none - case .follow(var city): - city.index = Int((state.followingList.last?.index ?? 0) + 1) - return environment.followingClient - .save(city) - .receive(on: environment.mainQueue) - .catchToEffect(ForecastAction.followDone) - case .followDone(let result): - if case let .success(city) = result { - state.followingList.append(city) - } - return .none - case .unfollowCity(let indexSet): - return environment.followingClient - .delete(state.followingList[indexSet.first!]) - .receive(on: environment.mainQueue) - .catchToEffect(ForecastAction.unfollowCityDone) - case .unfollowCityDone(let result): - if case let .success(city) = result { - state.followingList.removeAll(where: { $0.id == city.id }) - } - return .none - case let .moveCity(indexSet, toIndex): - return environment.followingClient - .move(state.followingList, indexSet, toIndex) - .receive(on: environment.mainQueue) - .catchToEffect(ForecastAction.fetchFollowingCityDone) - case .loadCityForecast(city: let city): - guard !state.loadingCityIDSet.contains(city.id) else { return .none } - state.loadingCityIDSet.insert(city.id) - return environment.weatherClient - .oneCall(city.coord.lat, city.coord.lon) - .receive(on: environment.mainQueue) - .catchToEffect { result in - ForecastAction.loadCityForecastDone(cityID: city.id, result: result) + @Dependency(\.weatherClient) var weatherClient + @Dependency(\.followingClient) var followingClient + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .fetchFollowingCity: + return .task { + await .fetchFollowingCityDone(TaskResult<[CityViewModel]> { + try await followingClient.fetch() + }) + } + case .fetchFollowingCityDone(let result): + switch result { + case .success(let list): + state.followingList = list + case .failure(let error): + customDump(error) + } + return .none + case .follow(let city): + return .task { [lastIndex = state.followingList.last?.index ?? 0] in + await .followDone(TaskResult { + var city = city + city.index = Int(lastIndex + 1) + return try followingClient.save(city) + }) + } + case .followDone(let result): + if case let .success(city) = result { + state.followingList.append(city) + } + return .none + case .unfollowCity(let indexSet): + return .task { [city = state.followingList[indexSet.first!]] in + await .unfollowCityDone(TaskResult { + try followingClient.delete(city) + }) + } + case .unfollowCityDone(let result): + if case let .success(city) = result { + state.followingList.removeAll(where: { $0.id == city.id }) + } + return .none + case let .moveCity(indexSet, toIndex): + return .task { [followingList = state.followingList] in + await .fetchFollowingCityDone(TaskResult<[CityViewModel]> { + try followingClient.move(followingList, indexSet, toIndex) + }) + } + case .loadCityForecast(city: let city): + guard !state.loadingCityIDSet.contains(city.id) else { return .none } + state.loadingCityIDSet.insert(city.id) + return .task { + await .loadCityForecastDone(cityID: city.id, result: TaskResult { + try await weatherClient.oneCall(city.coord.lat, city.coord.lon) + }) + } + case .loadCityForecastDone(cityID: let cityID, result: let result): + state.loadingCityIDSet.remove(cityID) + switch result { + case .success(let value): + var forecast = state.forecast ?? [:] + forecast[cityID] = value + state.forecast = forecast + case .failure(let error): + dump(error) + } + return .none } - case .loadCityForecastDone(cityID: let cityID, result: let result): - state.loadingCityIDSet.remove(cityID) - switch result { - case .success(let value): - var forecast = state.forecast ?? [:] - forecast[cityID] = value - state.forecast = forecast - case .failure(let error): - dump(error) } - return .none } } - .debug() diff --git a/Shared/State/DataFlow/SearchState.swift b/Shared/State/DataFlow/SearchState.swift index 19f14c0..7fdb1a8 100644 --- a/Shared/State/DataFlow/SearchState.swift +++ b/Shared/State/DataFlow/SearchState.swift @@ -1,62 +1,62 @@ import Foundation import ComposableArchitecture -struct SearchState: Equatable { +struct SearchReducer: ReducerProtocol { - @BindableState var searchQuery = "" - var list: [Find.City] = [] - var status: Status = .normal - - @BindableState var selectedCity: CityViewModel? + struct State: Equatable { + + @BindableState var searchQuery = "" + var list: [Find.City] = [] + var status: Status = .normal + + @BindableState var selectedCity: CityViewModel? + + enum Status: Equatable { + case normal, loading, noResult, failed(String) + } + } - enum Status: Equatable { - case normal, loading, noResult, failed(String) + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case search(query: String) + case citiesResponse(TaskResult<[Find.City]>) } -} - -enum SearchAction: Equatable, BindableAction { - case binding(BindingAction) - case search(query: String) - case citiesResponse(Result<[Find.City], AppError>) -} - -struct SearchEnvironment { - var mainQueue: AnySchedulerOf - var weatherClient: WeatherClient -} - -let searchReducer = Reducer { - state, action, environment in - switch action { - case .binding(let action): - if action.keyPath == \.$searchQuery, state.searchQuery.count == 0 { - state.status = .normal - state.list = [] - } - return .none - case .search(let query): - struct SearchCityId: Hashable { } - - guard state.status != .loading else { return .none } - state.status = .loading - return environment.weatherClient - .searchCity(query) - .receive(on: environment.mainQueue) - .catchToEffect(SearchAction.citiesResponse) - .cancellable(id: SearchCityId(), cancelInFlight: true) + @Dependency(\.weatherClient) var weatherClient - case .citiesResponse(let result): - switch result { - case .success(let list): - state.status = list.count > 0 ? .normal : .noResult - state.list = list - case .failure(let error): - state.status = .failed(error.localizedDescription) - state.list = [] + var body: some ReducerProtocol { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(let action): + if action.keyPath == \.$searchQuery, state.searchQuery.count == 0 { + state.status = .normal + state.list = [] + } + return .none + + case .search(let query): + struct SearchCityId: Hashable { } + + guard state.status != .loading else { return .none } + state.status = .loading + return .task { + await .citiesResponse(TaskResult<[Find.City]> { + try await weatherClient.searchCity(query) + }) + } + + case .citiesResponse(let result): + switch result { + case .success(let list): + state.status = list.count > 0 ? .normal : .noResult + state.list = list + case .failure(let error): + state.status = .failed(error.localizedDescription) + state.list = [] + } + return .none + } } - return .none } } - .binding() - .debug() diff --git a/Shared/State/DataFlow/WeatherState.swift b/Shared/State/DataFlow/WeatherState.swift index 374cf5e..2d462d1 100644 --- a/Shared/State/DataFlow/WeatherState.swift +++ b/Shared/State/DataFlow/WeatherState.swift @@ -1,56 +1,45 @@ import Foundation import ComposableArchitecture -struct WeatherState: Equatable { - var search: SearchState = .init() - var forecast: ForecastState = .init() -} - -enum WeatherAction: Equatable { - case search(SearchAction) - case forecast(ForecastAction) -} - -struct WeatherEnvironment { - var mainQueue: AnySchedulerOf - var weatherClient: WeatherClient - var followingClient: FollowingClient - var date: () -> Date -} - -let weatherReducer = Reducer.combine( - searchReducer.pullback( - state: \.search, - action: /WeatherAction.search, - environment: { - SearchEnvironment(mainQueue: $0.mainQueue, weatherClient: $0.weatherClient) +struct WeatherReducer: ReducerProtocol { + + struct State: Equatable { + var search: SearchReducer.State = .init() + var forecast: ForecastReducer.State = .init() + } + + enum Action: Equatable { + case search(SearchReducer.Action) + case forecast(ForecastReducer.Action) + } + + @Dependency(\.weatherClient) var weatherClient + @Dependency(\.date) var date + + var body: some ReducerProtocol { + Scope(state: \.search, action: /Action.search) { + SearchReducer() } - ), - forecastReducer.pullback( - state: \.forecast, - action: /WeatherAction.forecast, - environment: { - ForecastEnvironment( - mainQueue: $0.mainQueue, - weatherClient: $0.weatherClient, - followingClient: $0.followingClient - ) + Scope(state: \.forecast, action: /Action.forecast) { + ForecastReducer() } - ), - Reducer { state, action, environment in - switch action { - // MARK: - binding. 选择了 city,触发刷新天气预报。 - case .search(.binding(let binding)): - if binding.keyPath == \.$selectedCity, let city = state.search.selectedCity { - let forecast = state.forecast.forecast?[city.id] - if forecast == nil || (environment.date().timeIntervalSince1970 - Double(forecast!.current.dt)) > 600 { - return .init(value: .forecast(.loadCityForecast(city: city))) - } - } - return .none + Reduce { state, action in + switch action { + // MARK: - binding. 选择了 city,触发刷新天气预报。 + case .search(.binding(let binding)): + if binding.keyPath == \.$selectedCity, let city = state.search.selectedCity { + let forecast = state.forecast.forecast?[city.id] + if forecast == nil || (date().timeIntervalSince1970 - Double(forecast!.current.dt)) > 600 { + return .task { + .forecast(.loadCityForecast(city: city)) + } + } + } + return .none - default: return .none + default: return .none + } } } -) +} diff --git a/Shared/State/FileIO/FollowingClient.swift b/Shared/State/FileIO/FollowingClient.swift index 67ae334..2481584 100644 --- a/Shared/State/FileIO/FollowingClient.swift +++ b/Shared/State/FileIO/FollowingClient.swift @@ -1,27 +1,49 @@ import Foundation import ComposableArchitecture import CoreData +import XCTestDynamicOverlay struct FollowingClient { - var fetch: () -> Effect<[CityViewModel], AppError> - var save: (CityViewModel) -> Effect - var delete: (CityViewModel) -> Effect - var move: ([CityViewModel], _ indexSet: IndexSet, _ toIndex: Int) -> Effect<[CityViewModel], AppError> + var fetch: @Sendable () async throws -> [CityViewModel] + var save: @Sendable (CityViewModel) throws -> CityViewModel + var delete: @Sendable (CityViewModel) throws -> CityViewModel + var move: @Sendable ([CityViewModel], _ indexSet: IndexSet, _ toIndex: Int) throws -> [CityViewModel] } -extension FollowingClient { - static let live = Self( +extension DependencyValues { + var followingClient: FollowingClient { + get { self[FollowingClient.self] } + set { self[FollowingClient.self] = newValue } + } +} + +extension FollowingClient: DependencyKey { + static var liveValue: FollowingClient = Self( fetch: { - Persistence.shared.fetch(FollowingCity.self, sortDescriptors: [ + let context = Persistence.shared.viewContext + let request = FollowingCity.fetchRequest() + request.sortDescriptors = [ .init(keyPath: \FollowingCity.index, ascending: true) - ]) - .map { $0.map(CityViewModel.init) } + ] + return try context.fetch(request).map(CityViewModel.init) }, save: { - Persistence.shared.save($0) - .map { .init(city: $0 as! FollowingCity) } + let viewContext = Persistence.shared.viewContext + let object = $0.instance(with: viewContext) + if viewContext.hasChanges { + try viewContext.save() + } + return $0 + }, + delete: { + let viewContext = Persistence.shared.viewContext + let object = $0.instance(with: viewContext) + viewContext.delete(object) + if viewContext.hasChanges { + try viewContext.save() + } + return $0 }, - delete: Persistence.shared.delete, move: { list, indexSet, toIndex in var list = list list.move(fromOffsets: indexSet, toOffset: toIndex) @@ -40,20 +62,25 @@ extension FollowingClient { } } - do { - try Persistence.shared.viewContext.save() - return Effect(value: list) - } catch { - return Effect(error: .networkingFailed(error)) - } + try Persistence.shared.viewContext.save() + return list } ) + static var previewValue: FollowingClient = Self { + testCities.map(CityViewModel.init) + } + save: { $0 } + delete: { $0 } + move: { list, _, _ in + list + } - static let falling = Self( - fetch: { .failing("FollowingClient.fetch") }, - save: { _ in .failing("FollowingClient.save") }, - delete: { _ in .failing("FollowingClient.delete") }, - move: { _,_,_ in .failing("FollowingClient.move") } - ) + static var testValue: FollowingClient = Self { + testCities.map(CityViewModel.init) + } + save: { $0 } + delete: { $0 } + move: { list, _, _ in + list + } } - diff --git a/Shared/State/Networking/WeatherClient.swift b/Shared/State/Networking/WeatherClient.swift index b26c16e..218007a 100644 --- a/Shared/State/Networking/WeatherClient.swift +++ b/Shared/State/Networking/WeatherClient.swift @@ -1,29 +1,32 @@ import Foundation import ComposableArchitecture +import XCTestDynamicOverlay struct WeatherClient { - var searchCity: (String) -> Effect<[Find.City], AppError> - var oneCall: (_ lat: Double, _ lon: Double) -> Effect + var searchCity: @Sendable (String) async throws -> [Find.City] + var oneCall: @Sendable (_ lat: Double, _ lon: Double) async throws -> OneCall } private let appid = "" -extension WeatherClient { - - static let live = WeatherClient( +extension DependencyValues { + var weatherClient: WeatherClient { + get { self[WeatherClient.self] } + set { self[WeatherClient.self] = newValue } + } +} + +extension WeatherClient: DependencyKey { + static var liveValue: WeatherClient = Self( searchCity: { query in guard let q = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: "https://openweathermap.org/data/2.5/find?q=\(q)&appid=\(appid)&units=metric") else { - return Effect(error: .badURL) + throw AppError.badURL } - return URLSession.shared.dataTaskPublisher(for: url) - .map { $0.data } - .decode(type: Find.self, decoder: JSONDecoder()) - .map { $0.list } - .mapError { AppError.networkingFailed($0) } - .eraseToEffect() + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode(Find.self, from: data).list }, oneCall: { lat, lon in var components = URLComponents(string: "https://openweathermap.org/data/2.5/onecall") @@ -34,19 +37,21 @@ extension WeatherClient { .init(name: "lon", value: String(lon)), ] guard let url = components?.url else { - return Effect(error: .badURL) + throw AppError.badURL } - return URLSession.shared - .dataTaskPublisher(for: url) - .map { $0.data } - .decode(type: OneCall.self, decoder: JSONDecoder()) - .mapError { AppError.networkingFailed($0) } - .eraseToEffect() + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode(OneCall.self, from: data) } ) - static let failing = Self( - searchCity: { _ in .failing("WeatherClient.searchCity") }, - oneCall: { _,_ in .failing("WeatherClient.oneCall") } + static var previewValue: WeatherClient = Self { _ in + testCities + } oneCall: { _, _ in + testOneCall + } + + static var testValue: WeatherClient = Self( + searchCity: unimplemented("WeatherClient.searchCity"), + oneCall: unimplemented("WeatherClient.oneCall") ) } diff --git a/Shared/View/Forecast/CityView.swift b/Shared/View/Forecast/CityView.swift index dcceb06..3669e25 100644 --- a/Shared/View/Forecast/CityView.swift +++ b/Shared/View/Forecast/CityView.swift @@ -3,7 +3,7 @@ import Kingfisher import ComposableArchitecture struct CityView: View { - let store: Store + let store: StoreOf let city: CityViewModel @State private var selectedDailyIndex: Int = 0 @@ -188,19 +188,21 @@ private struct DailyView: View { } } +#if DEBUG struct CityView_Previews: PreviewProvider { static var previews: some View { - let city = CityViewModel(city: SearchView_Previews.debugList()[0]) - return CityView( - store: .init( - initialState: .init(), - reducer: forecastReducer, - environment: ForecastEnvironment( - mainQueue: .main, - weatherClient: .live, - followingClient: .live - ) - ), - city: city) + let city = CityViewModel(city: testCities[0]) + let store: StoreOf = .init( + initialState: .init(), + reducer: ForecastReducer() + ) + + WithViewStore(store, observe: { $0 }) { viewStore in + CityView(store: store, city: city) + .task { + viewStore.send(.loadCityForecast(city: city)) + } + } } } +#endif diff --git a/Shared/View/SearchView.swift b/Shared/View/SearchView.swift index 7e0ede8..4e3e719 100644 --- a/Shared/View/SearchView.swift +++ b/Shared/View/SearchView.swift @@ -3,19 +3,19 @@ import ComposableArchitecture import Kingfisher struct SearchView: View { - let store: Store + let store: StoreOf - var searchStore: Store { + var searchStore: StoreOf { store.scope( state: \.search, - action: WeatherAction.search + action: WeatherReducer.Action.search ) } - var forecastStore: Store { + var forecastStore: StoreOf { store.scope( state: \.forecast, - action: WeatherAction.forecast + action: WeatherReducer.Action.forecast ) } @@ -45,54 +45,32 @@ struct SearchView: View { } #endif } detail: { - VStack { - IfLetStore( - store.scope(state: \.search.selectedCity) - ) { letStore in - WithViewStore(letStore) { letViewStore in - CityView(store: forecastStore, city: letViewStore.state) - } - } else: { - Image(systemName: "cloud.sun").font(.largeTitle) + IfLetStore( + store.scope(state: \.search.selectedCity) + ) { letStore in + WithViewStore(letStore) { letViewStore in + CityView(store: forecastStore, city: letViewStore.state) } + } else: { + Image(systemName: "cloud.sun").font(.largeTitle) } } } } } +#if DEBUG struct SearchView_Previews: PreviewProvider { - static func debugList() -> [Find.City] { - guard let url = Bundle.main.url(forResource: "Cities", withExtension: "json"), - let data = try? Data(contentsOf: url), - let list = try? JSONDecoder().decode([Find.City].self, from: data) - else { return [] } - return list - } - static var previews: some View { - let store = Store( - initialState: WeatherState(search: .init(searchQuery: "preview", - list: debugList()), - forecast: .init()), - reducer: weatherReducer, - environment: WeatherEnvironment( - mainQueue: .main, - weatherClient: WeatherClient( - searchCity: { _ in - Effect(value: [ - debugList()[0] - ]) - }, - oneCall: { _,_ in Effect(error: .badURL) } - ), - followingClient: .live, - date: Date.init + SearchView( + store: .init( + initialState: .init(), + reducer: WeatherReducer() ) ) - return SearchView(store: store) } } +#endif private extension Text { func headerText() -> some View { @@ -102,7 +80,7 @@ private extension Text { } struct SearchSection: View { - let viewStore: ViewStore + let viewStore: ViewStore var body: some View { switch (viewStore.status, viewStore.searchQuery.count) { @@ -124,7 +102,7 @@ struct SearchSection: View { } struct FollowingSection: View { - let store: Store + let store: StoreOf var body: some View { WithViewStore(store) { viewStore in diff --git a/Shared/WeatherApp.swift b/Shared/WeatherApp.swift index 7b0421c..9c7dcef 100644 --- a/Shared/WeatherApp.swift +++ b/Shared/WeatherApp.swift @@ -7,23 +7,17 @@ struct WeatherApp: App { SearchView( store: .init( initialState: .init(), - reducer: weatherReducer, - environment: WeatherEnvironment( - mainQueue: .main, - weatherClient: .live, - followingClient: .live, - date: Date.init - ) + reducer: WeatherReducer() ) ) - .withHostingWindow { window in - #if targetEnvironment(macCatalyst) - if let titleBar = window?.windowScene?.titlebar { - titleBar.titleVisibility = .hidden - titleBar.toolbar = nil - } - #endif + .withHostingWindow { window in + #if targetEnvironment(macCatalyst) + if let titleBar = window?.windowScene?.titlebar { + titleBar.titleVisibility = .hidden + titleBar.toolbar = nil } + #endif + } } } } diff --git a/WeatherApp.xcodeproj/project.pbxproj b/WeatherApp.xcodeproj/project.pbxproj index b45b920..ba09ea0 100644 --- a/WeatherApp.xcodeproj/project.pbxproj +++ b/WeatherApp.xcodeproj/project.pbxproj @@ -8,10 +8,8 @@ /* Begin PBXBuildFile section */ 0979807727F6ECDD0034DE5E /* WeatherAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0979807627F6ECDD0034DE5E /* WeatherAppTests.swift */; }; - 0979807F27F6F85F0034DE5E /* Cities.json in Resources */ = {isa = PBXBuildFile; fileRef = 2DD4A48DEC99D8B66CF1D4BB /* Cities.json */; }; - 09D5313127F826A3008750B2 /* OneCall.json in Resources */ = {isa = PBXBuildFile; fileRef = 09D5313027F826A3008750B2 /* OneCall.json */; }; - 09D5313227F826A3008750B2 /* OneCall.json in Resources */ = {isa = PBXBuildFile; fileRef = 09D5313027F826A3008750B2 /* OneCall.json */; }; - 09D5313327F826A3008750B2 /* OneCall.json in Resources */ = {isa = PBXBuildFile; fileRef = 09D5313027F826A3008750B2 /* OneCall.json */; }; + 09CC400929120629005A94CA /* TestCitiesData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09CC400829120629005A94CA /* TestCitiesData.swift */; }; + 09CC400B29120793005A94CA /* TestOneCallData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09CC400A29120793005A94CA /* TestOneCallData.swift */; }; 09D5313527F8331A008750B2 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09D5313427F8331A008750B2 /* SearchView.swift */; }; 09D5313627F8331A008750B2 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09D5313427F8331A008750B2 /* SearchView.swift */; }; 09D5313B27F84521008750B2 /* WeatherState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09D5313A27F84521008750B2 /* WeatherState.swift */; }; @@ -24,7 +22,6 @@ 2DD4A245B64950586DB890A5 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD4A5C3735A0651D5E90BEC /* Helpers.swift */; }; 2DD4A2C3C7392797FB2D97C0 /* CityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD4AE09EF4543E228B4AEFB /* CityViewModel.swift */; }; 2DD4A2D7377AA6B53B0EB0E3 /* CityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD4ADEE73AD1523C7D58AF7 /* CityView.swift */; }; - 2DD4A395D3720D00CB30AD18 /* Cities.json in Resources */ = {isa = PBXBuildFile; fileRef = 2DD4A48DEC99D8B66CF1D4BB /* Cities.json */; }; 2DD4A47929C6DEEBD65D7B3B /* FindModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD4A9648712DA7876CDB559 /* FindModel.swift */; }; 2DD4A4E519D1B2D0B72A0A15 /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD4AA3F58655974B261A5EB /* AppError.swift */; }; 2DD4A5232871174EDE81C507 /* FileStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD4AC81862725044BF0B250 /* FileStorage.swift */; }; @@ -34,7 +31,6 @@ 2DD4A90B5D784573B7F8B96E /* DailyForecastDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD4A9D929C2A3DA0F365D0D /* DailyForecastDetailView.swift */; }; 2DD4A90C0BF3A4601BCD6957 /* CityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD4AE09EF4543E228B4AEFB /* CityViewModel.swift */; }; 2DD4AB09F382F6374C636728 /* OneCallModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD4A0AAEC3309B973D7B85C /* OneCallModel.swift */; }; - 2DD4AB65BDD704FB3596E6AF /* Cities.json in Resources */ = {isa = PBXBuildFile; fileRef = 2DD4A48DEC99D8B66CF1D4BB /* Cities.json */; }; 2DD4AEB5967B88CC9654206E /* WeatherItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD4AACF70AD827AF79D5A2C /* WeatherItems.swift */; }; 2DD4AEDBED8084AABAD915C2 /* FileStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD4AC81862725044BF0B250 /* FileStorage.swift */; }; 2DD4AFDBCC25F2819D6695D8 /* WeatherItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD4AACF70AD827AF79D5A2C /* WeatherItems.swift */; }; @@ -71,11 +67,11 @@ /* Begin PBXFileReference section */ 0979807427F6ECDD0034DE5E /* WeatherAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WeatherAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 0979807627F6ECDD0034DE5E /* WeatherAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherAppTests.swift; sourceTree = ""; }; - 09D5313027F826A3008750B2 /* OneCall.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = OneCall.json; sourceTree = ""; }; + 09CC400829120629005A94CA /* TestCitiesData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestCitiesData.swift; sourceTree = ""; }; + 09CC400A29120793005A94CA /* TestOneCallData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestOneCallData.swift; sourceTree = ""; }; 09D5313427F8331A008750B2 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 09D5313A27F84521008750B2 /* WeatherState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherState.swift; sourceTree = ""; }; 2DD4A0AAEC3309B973D7B85C /* OneCallModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OneCallModel.swift; sourceTree = ""; }; - 2DD4A48DEC99D8B66CF1D4BB /* Cities.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = Cities.json; sourceTree = ""; }; 2DD4A5C3735A0651D5E90BEC /* Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; 2DD4A9648712DA7876CDB559 /* FindModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindModel.swift; sourceTree = ""; }; 2DD4A9D929C2A3DA0F365D0D /* DailyForecastDetailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DailyForecastDetailView.swift; sourceTree = ""; }; @@ -135,13 +131,13 @@ path = WeatherAppTests; sourceTree = ""; }; - 2DD4A16006EB20D82E54424E /* JSON */ = { + 09CC4007291205A2005A94CA /* Preview Content */ = { isa = PBXGroup; children = ( - 2DD4A48DEC99D8B66CF1D4BB /* Cities.json */, - 09D5313027F826A3008750B2 /* OneCall.json */, + 09CC400829120629005A94CA /* TestCitiesData.swift */, + 09CC400A29120793005A94CA /* TestOneCallData.swift */, ); - path = JSON; + path = "Preview Content"; sourceTree = ""; }; 2DD4A1DC6850EDCC09B9F5EA /* ViewModel */ = { @@ -265,7 +261,7 @@ 2DD4A27EDA437BA6BF09A8ED /* State */, 2DD4AD75121AEE4E2D0EA71F /* Model */, 2DD4A33CE0860E02FE1D2D9C /* View */, - 2DD4A16006EB20D82E54424E /* JSON */, + 09CC4007291205A2005A94CA /* Preview Content */, ); path = Shared; sourceTree = ""; @@ -413,8 +409,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 09D5313327F826A3008750B2 /* OneCall.json in Resources */, - 0979807F27F6F85F0034DE5E /* Cities.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -423,8 +417,6 @@ buildActionMask = 2147483647; files = ( 5CB711E327E72AE200876C83 /* Assets.xcassets in Resources */, - 09D5313127F826A3008750B2 /* OneCall.json in Resources */, - 2DD4AB65BDD704FB3596E6AF /* Cities.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -433,8 +425,6 @@ buildActionMask = 2147483647; files = ( 5CB711E427E72AE200876C83 /* Assets.xcassets in Resources */, - 09D5313227F826A3008750B2 /* OneCall.json in Resources */, - 2DD4A395D3720D00CB30AD18 /* Cities.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -457,9 +447,11 @@ 2DD4A182B66837A39D9590C2 /* Helpers.swift in Sources */, 09D5313527F8331A008750B2 /* SearchView.swift in Sources */, 5C97180828013C0400C0DED8 /* Persistence.swift in Sources */, + 09CC400929120629005A94CA /* TestCitiesData.swift in Sources */, 09D5313B27F84521008750B2 /* WeatherState.swift in Sources */, 2DD4A03FD8DF6E69B087B19C /* OneCallModel.swift in Sources */, 2DD4A4E519D1B2D0B72A0A15 /* AppError.swift in Sources */, + 09CC400B29120793005A94CA /* TestOneCallData.swift in Sources */, 5CC3DB1D27F7E4A7007B1AA7 /* ForecastState.swift in Sources */, 2DD4A5681EFA062E6386137C /* FindModel.swift in Sources */, 2DD4A2C3C7392797FB2D97C0 /* CityViewModel.swift in Sources */, @@ -672,6 +664,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Shared/Preview Content\""; DEVELOPMENT_TEAM = 3S89AQP84V; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; ENABLE_PREVIEWS = YES; @@ -711,6 +704,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Shared/Preview Content\""; DEVELOPMENT_TEAM = 3S89AQP84V; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; ENABLE_PREVIEWS = YES; @@ -849,8 +843,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture"; requirement = { - branch = main; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 0.45.0; }; }; 5CCEE9C427E86547007428C2 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { diff --git a/WeatherApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WeatherApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4bc43a0..72d0a5a 100644 --- a/WeatherApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/WeatherApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "4cf088c29a20f52be0f2ca54992b492c54e0076b", - "version" : "0.5.3" + "revision" : "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", + "version" : "0.9.1" } }, { @@ -23,8 +23,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "241301b67d8551c26d8f09bd2c0e52cc49f18007", - "version" : "0.8.0" + "revision" : "bb436421f57269fbcfe7360735985321585a86e5", + "version" : "0.10.1" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "692ec4f5429a667bdd968c7260dfa2b23adfeffc", + "version" : "0.1.4" } }, { @@ -41,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "branch" : "main", - "revision" : "97c3a498de2b0c0aed96fc3836195ebde720e822" + "revision" : "1fcd53fc875bade47d850749ea53c324f74fd64d", + "version" : "0.45.0" } }, { @@ -50,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "51698ece74ecf31959d3fa81733f0a5363ef1b4e", - "version" : "0.3.0" + "revision" : "819d9d370cd721c9d87671e29d947279292e4541", + "version" : "0.6.0" } }, { @@ -59,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "680bf440178a78a627b1c2c64c0855f6523ad5b9", - "version" : "0.3.2" + "revision" : "bfb0d43e75a15b6dfac770bf33479e8393884a36", + "version" : "0.4.1" } }, { @@ -68,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "50a70a9d3583fe228ce672e8923010c8df2deddd", - "version" : "0.2.1" + "revision" : "16e6409ee82e1b81390bdffbf217b9c08ab32784", + "version" : "0.5.0" } } ], diff --git a/WeatherAppTests/WeatherAppTests.swift b/WeatherAppTests/WeatherAppTests.swift index ec2c8f0..304bc49 100644 --- a/WeatherAppTests/WeatherAppTests.swift +++ b/WeatherAppTests/WeatherAppTests.swift @@ -2,198 +2,156 @@ import XCTest import ComposableArchitecture @testable import WeatherApp +@MainActor class WeatherAppTests: XCTestCase { - let scheduler = DispatchQueue.test - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testSearchAndClear() throws { + func testSearchAndClear() async throws { let store = TestStore( initialState: .init(), - reducer: searchReducer, - environment: SearchEnvironment( - mainQueue: scheduler.eraseToAnyScheduler(), - weatherClient: .failing) + reducer: SearchReducer() ) + store.dependencies.weatherClient.searchCity = { _ in testCities } - store.environment.weatherClient.searchCity = { _ in Effect(value: mockCities) } - store.send(.search(query: "Beijing")) { + _ = await store.send(.search(query: "Beijing")) { $0.status = .loading } - scheduler.advance(by: 0.3) - store.receive(.citiesResponse(.success(mockCities))) { + + await store.receive(.citiesResponse(.success(testCities))) { $0.status = .normal - $0.list = mockCities + $0.list = testCities } - store.send(.binding(.set(\.$searchQuery, ""))) { + _ = await store.send(.binding(.set(\.$searchQuery, ""))) { $0.searchQuery = "" $0.status = .normal $0.list = [] } } - func testSearchFailure() { + func testSearchFailure() async { let store = TestStore( initialState: .init(), - reducer: searchReducer, - environment: SearchEnvironment( - mainQueue: scheduler.eraseToAnyScheduler(), - weatherClient: .failing - ) + reducer: SearchReducer() ) - store.environment.weatherClient.searchCity = { _ in Effect(error: .badURL) } - store.send(.search(query: "S")) { + + let error = NSError(domain: "error", code: -999) + + store.dependencies.weatherClient.searchCity = { _ in throw error } + _ = await store.send(.search(query: "S")) { $0.status = .loading } - scheduler.advance(by: 0.3) - store.receive(.citiesResponse(.failure(.badURL))) { - $0.status = .failed("无效 URL") + + await store.receive(.citiesResponse(.failure(error))) { + $0.status = .failed(error.localizedDescription) } } - func testFollowingCity() { + func testFollowingCity() async { let store = TestStore( - initialState: .init( - followingList: mockCities.map(CityViewModel.init) - ), - reducer: forecastReducer, - environment: ForecastEnvironment( - mainQueue: .immediate, - weatherClient: .failing, - followingClient: .falling - ) + initialState: .init(), + reducer: ForecastReducer() ) - - let mockList = mockCities.map(CityViewModel.init) - store.environment.followingClient.fetch = { Effect(value: mockList) } - store.send(.fetchFollowingCity) - store.receive(.fetchFollowingCityDone(.success(mockList))) { + + let mockList = testCities.map(CityViewModel.init) + store.dependencies.followingClient.fetch = { mockList } + _ = await store.send(.fetchFollowingCity) + await store.receive(.fetchFollowingCityDone(.success(mockList))) { $0.followingList = mockList } - + let city = mockList[0] - store.environment.followingClient.delete = { _ in Effect(value: city) } - store.send(.unfollowCity(indexSet: IndexSet(integer: 0))) - store.receive(.unfollowCityDone(.success(city))) { + store.dependencies.followingClient.delete = { _ in city } + _ = await store.send(.unfollowCity(indexSet: IndexSet(integer: 0))) + await store.receive(.unfollowCityDone(.success(city))) { $0.followingList.remove(at: 0) } - - store.environment.followingClient.save = { _ in Effect(value: city) } - store.send(.follow(city: city)) - store.receive(.followDone(.success(city))) { state in + + store.dependencies.followingClient.save = { _ in city } + _ = await store.send(.follow(city: city)) + await store.receive(.followDone(.success(city))) { state in state.followingList.append(city) } - + + store.dependencies.followingClient.move = { _,_,_ in + var moved = mockList + moved.move(fromOffsets: IndexSet(integer: 1), toOffset: 0) + return moved + } + _ = await store.send(.moveCity(indexSet: IndexSet(integer: 1), toIndex: 0)) var moved = mockList moved.move(fromOffsets: IndexSet(integer: 1), toOffset: 0) - store.environment.followingClient.move = { _,_,_ in Effect(value: moved) } - store.send(.moveCity(indexSet: IndexSet(integer: 1), toIndex: 0)) - store.receive(.fetchFollowingCityDone(.success(moved))) { + await store.receive(.fetchFollowingCityDone(.success(moved))) { $0.followingList = moved } } - func testLoadCityForecast() { + func testLoadCityForecast() async { let store = TestStore( initialState: .init(), - reducer: forecastReducer, - environment: ForecastEnvironment( - mainQueue: scheduler.eraseToAnyScheduler(), - weatherClient: .failing, - followingClient: .falling - ) + reducer: ForecastReducer() ) - store.environment.weatherClient.oneCall = { _, _ in Effect(value: mockOneCall) } - store.send(.loadCityForecast(city: CityViewModel(city: mockCities[0]))) { - $0.loadingCityIDSet = [mockCities[0].id] + store.dependencies.weatherClient.oneCall = { _, _ in testOneCall } + _ = await store.send(.loadCityForecast(city: CityViewModel(city: testCities[0]))) { + $0.loadingCityIDSet = [testCities[0].id] } - scheduler.advance(by: 0.3) - store.receive(.loadCityForecastDone(cityID: mockCities[0].id, result: .success(mockOneCall))) { + + await store.receive(.loadCityForecastDone(cityID: testCities[0].id, result: .success(testOneCall))) { $0.loadingCityIDSet = [] var forecast = $0.forecast ?? [:] - forecast[mockCities[0].id] = mockOneCall + forecast[testCities[0].id] = testOneCall $0.forecast = forecast } } - func testLoadCityForecastFailure() { + func testLoadCityForecastFailure() async { let store = TestStore( initialState: .init(), - reducer: forecastReducer, - environment: ForecastEnvironment( - mainQueue: scheduler.eraseToAnyScheduler(), - weatherClient: .failing, - followingClient: .falling - ) + reducer: ForecastReducer() ) - store.environment.weatherClient.oneCall = { _,_ in Effect(error: .badURL) } - store.send(.loadCityForecast(city: CityViewModel(city: mockCities[0]))) { state in - state.loadingCityIDSet = [mockCities[0].id] + + let error = NSError(domain: "error", code: -999) + + store.dependencies.weatherClient.oneCall = { _,_ in throw error } + _ = await store.send(.loadCityForecast(city: CityViewModel(city: testCities[0]))) { state in + state.loadingCityIDSet = [testCities[0].id] } - scheduler.advance(by: 0.3) - store.receive(.loadCityForecastDone(cityID: mockCities[0].id, result: .failure(.badURL))) { + + await store.receive(.loadCityForecastDone(cityID: testCities[0].id, result: .failure(error))) { $0.loadingCityIDSet = [] } } - func testSelectCity() { + func testSelectCity() async { let store = TestStore( initialState: .init(), - reducer: weatherReducer, - environment: .init( - mainQueue: .immediate, - weatherClient: .failing, - followingClient: .falling, - date: { Date(timeIntervalSince1970: 1648699300) } - ) + reducer: WeatherReducer() ) - - let city = CityViewModel(city: SearchView_Previews.debugList()[0]) - store.environment.weatherClient.oneCall = { _, _ in - .init(value: mockOneCall) + + let city = CityViewModel(city: testCities[0]) + store.dependencies.weatherClient.oneCall = { _, _ in + testOneCall } + + store.dependencies.date = .constant(Date(timeIntervalSince1970: 100)) // 测试选中城市 - store.send(.search(.binding(.set(\.$selectedCity, city)))) { + _ = await store.send(.search(.binding(.set(\.$selectedCity, city)))) { $0.search.selectedCity = city } // 触发加载城市天气预报 - store.receive(.forecast(.loadCityForecast(city: city))) { + await store.receive(.forecast(.loadCityForecast(city: city))) { $0.forecast.loadingCityIDSet = [city.id] } - + // 成功加载 - store.receive(.forecast(.loadCityForecastDone(cityID: city.id, result: .success(mockOneCall)))) { + await store.receive(.forecast(.loadCityForecastDone(cityID: city.id, result: .success(testOneCall)))) { $0.forecast.loadingCityIDSet = [] - $0.forecast.forecast = [city.id: mockOneCall] + $0.forecast.forecast = [city.id: testOneCall] } // 再次点击城市,两次间隔不超过 600 秒,不触发刷新 - store.send(.search(.binding(.set(\.$selectedCity, city)))) + _ = await store.send(.search(.binding(.set(\.$selectedCity, city)))) } } - -private let mockCities: [Find.City] = { - guard let url = Bundle.main.url(forResource: "Cities", withExtension: "json"), - let data = try? Data(contentsOf: url), - let list = try? JSONDecoder().decode([Find.City].self, from: data) - else { return [] } - return list -}() - -private let mockOneCall: OneCall = { - guard let url = Bundle.main.url(forResource: "OneCall", withExtension: "json"), - let data = try? Data(contentsOf: url), - let model = try? JSONDecoder().decode(OneCall.self, from: data) - else { fatalError() } - return model -}()