Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

既存コードをAPI Guidelineに準拠させる #72

Merged
merged 25 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8ba2766
edit: rename callbackFetchWeatherList to fetchWeather
daikiumehara Apr 26, 2024
9520e85
edit: remove unnecessary makeResponse code
daikiumehara Apr 26, 2024
d385448
edit: refactor casting process
daikiumehara Apr 30, 2024
ebd0419
Example のテストコードで WeatherModel プロトコルの要求を満たせていなかったのを修正しました。
es-kumagai Apr 30, 2024
50276c9
Example テストコード内で、loadWeather メソッドにその呼び出し主が渡されていないのを修正しました。
es-kumagai Apr 30, 2024
70f228f
WeatherViewController に DisasterModelMock が設定されていないことが原因で、テストが強制終了するの…
es-kumagai Apr 30, 2024
2d2da35
WeatherImageView への反映を待たずに XCTAssertEqual で結果判定をして失敗していた箇所を修正しました。
es-kumagai Apr 30, 2024
f8d13ae
テストコード内の typo を修正しました。
es-kumagai Apr 30, 2024
81f35eb
テストコードでの View の準備は、ビューへのプロパティーアクセスを捨てる方法ではなく 'loadViewIfNeeded` を呼び出す…
es-kumagai Apr 30, 2024
c1fb6be
少なくとも現状のテストコードでは WeatherModelMock を 'setUpWithError' まで待たなくても準備できるため、…
es-kumagai Apr 30, 2024
acb3dda
単独で存在していた、不必要なドキュメントコメントを削除しました。
es-kumagai Apr 30, 2024
ee6cf6d
self. は省略される傾向があるため、それが明らかに不要な箇所については省略しました。
es-kumagai Apr 30, 2024
17daa5f
ThreadBlock の課題文に、使用する API の特徴説明を追記しました。
es-kumagai May 1, 2024
b7bf09c
YumemiWeather クラスの見通しを良くするために、外部公開する API の機能とそれ以外とを区別して定義するようにしました。
es-kumagai May 1, 2024
4a081da
Revert "edit: rename callbackFetchWeatherList to fetchWeather"
es-kumagai May 1, 2024
6965ed4
不必要な名前空間の指定を削除しました。
es-kumagai May 2, 2024
ddef528
エンコードされたものが適切にデコードできるように修正しました。
es-kumagai May 2, 2024
6911774
乱数生成器が独自定義されていることが想像しやすい名前 ControllableGenerator に変更し、その渡し方を標準ライブラリーに…
es-kumagai May 2, 2024
121b753
標準ライブラリーの作法に基づいて、WeatherCondition をランダムで生成するコードを変更しました。
es-kumagai May 2, 2024
3193e27
不必要な名前空間指定を削除しました。
es-kumagai May 2, 2024
718cc61
ドキュメントコメントの誤字を修正しました。
es-kumagai May 2, 2024
feee440
不必要な名前空間の明記を省略しました。
es-kumagai May 2, 2024
6d986e1
YumemiWeather の List 取得用 API で、地域に空を指定すると全地域の予報が取得できる旨をドキュメントコメントに記載し…
yumemi-kumagai May 9, 2024
9cfb85b
YumemiWeather API が無作為にエラーを返す頻度をコントロール可能にしました。
es-kumagai May 2, 2024
95aa9c9
Merge pull request #81 from yumemi-inc/feature/controllable_api_error
es-kumagai May 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Documentation/ThreadBlock.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ Sync ver
```swift
static func syncFetchWeather(_ jsonString: String) throws -> String
```

このメソッドは、値を返すまでに少し時間がかかります。

[APIの概要](YumemiWeather.md)

## 課題
Expand Down
6 changes: 3 additions & 3 deletions Example/Example/Model/DisasterModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ class DisasterModelImpl: DisasterModel {
}

func fetchDisaster(completion: ((String) -> Void)?) {
self.fetchDisasterHandler = completion
self.yumemiDisaster.fetchDisaster()
fetchDisasterHandler = completion
yumemiDisaster.fetchDisaster()
}
}

extension DisasterModelImpl: YumemiDisasterHandleDelegate {

func handle(disaster: String) {
self.fetchDisasterHandler?(disaster)
fetchDisasterHandler?(disaster)
}

}
26 changes: 13 additions & 13 deletions Example/Example/UI/Weather/WeatherViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class WeatherViewController: UIViewController {
super.viewDidLoad()

NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { [unowned self] notification in
self.loadWeather(notification.object)
loadWeather(notification.object)
}
}

Expand All @@ -39,11 +39,11 @@ class WeatherViewController: UIViewController {
}

@IBAction func dismiss(_ sender: Any) {
self.dismiss(animated: true, completion: nil)
dismiss(animated: true, completion: nil)
}

@IBAction func loadWeather(_ sender: Any?) {
self.activityIndicator.startAnimating()
activityIndicator.startAnimating()
weatherModel.fetchWeather(at: "tokyo", date: Date()) { result in
DispatchQueue.main.async {
self.activityIndicator.stopAnimating()
Expand All @@ -58,9 +58,9 @@ class WeatherViewController: UIViewController {
func handleWeather(result: Result<Response, WeatherError>) {
switch result {
case .success(let response):
self.weatherImageView.set(weather: response.weather)
self.minTempLabel.text = String(response.minTemp)
self.maxTempLabel.text = String(response.maxTemp)
weatherImageView.set(weather: response.weather)
minTempLabel.text = String(response.minTemp)
maxTempLabel.text = String(response.maxTemp)

case .failure(let error):
let message: String
Expand All @@ -79,7 +79,7 @@ class WeatherViewController: UIViewController {
print("Close ViewController by \(alertController)")
}
})
self.present(alertController, animated: true, completion: nil)
present(alertController, animated: true, completion: nil)
}
}
}
Expand All @@ -88,14 +88,14 @@ private extension UIImageView {
func set(weather: Weather) {
switch weather {
case .sunny:
self.image = R.image.sunny()
self.tintColor = R.color.red()
image = R.image.sunny()
tintColor = R.color.red()
case .cloudy:
self.image = R.image.cloudy()
self.tintColor = R.color.gray()
image = R.image.cloudy()
tintColor = R.color.gray()
case .rainy:
self.image = R.image.rainy()
self.tintColor = R.color.blue()
image = R.image.rainy()
tintColor = R.color.blue()
}
}
}
90 changes: 57 additions & 33 deletions Example/ExampleTests/WeatherViewControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,66 +12,90 @@ import YumemiWeather

class WeatherViewControllerTests: XCTestCase {

var weahterViewController: WeatherViewController!
var weahterModel: WeatherModelMock!
var weatherViewController: WeatherViewController!
var weatherModel = WeatherModelMock()

override func setUpWithError() throws {
weahterModel = WeatherModelMock()
weahterViewController = R.storyboard.weather.instantiateInitialViewController()!
weahterViewController.weatherModel = weahterModel
_ = weahterViewController.view
weatherViewController = R.storyboard.weather.instantiateInitialViewController()!
weatherViewController.weatherModel = weatherModel
weatherViewController.disasterModel = DisasterModelMock()

weatherViewController.loadViewIfNeeded()
}

override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}

func test_天気予報がsunnyだったらImageViewのImageにsunnyが設定されること_TintColorがredに設定されること() throws {
weahterModel.fetchWeatherImpl = { _ in
Response(weather: .sunny, maxTemp: 0, minTemp: 0, date: Date())
@MainActor
func test_天気予報がsunnyだったらImageViewのImageにsunnyが設定されること_TintColorがredに設定されること() async throws {
weatherModel.fetchWeatherImpl = { _ in
let response = Response(weather: .sunny, maxTemp: 0, minTemp: 0, date: Date())
return Result.success(response)
}

weahterViewController.loadWeather()
XCTAssertEqual(weahterViewController.weatherImageView.tintColor, R.color.red())
XCTAssertEqual(weahterViewController.weatherImageView.image, R.image.sunny())
weatherViewController.loadWeather(self)
await Task.yield()

XCTAssertEqual(weatherViewController.weatherImageView.tintColor, R.color.red())
XCTAssertEqual(weatherViewController.weatherImageView.image, R.image.sunny())
}

func test_天気予報がcloudyだったらImageViewのImageにcloudyが設定されること_TintColorがgrayに設定されること() throws {
weahterModel.fetchWeatherImpl = { _ in
Response(weather: .cloudy, maxTemp: 0, minTemp: 0, date: Date())
@MainActor
func test_天気予報がcloudyだったらImageViewのImageにcloudyが設定されること_TintColorがgrayに設定されること() async throws {
weatherModel.fetchWeatherImpl = { _ in
let response = Response(weather: .cloudy, maxTemp: 0, minTemp: 0, date: Date())
return Result.success(response)
}

weahterViewController.loadWeather()
XCTAssertEqual(weahterViewController.weatherImageView.tintColor, R.color.gray())
XCTAssertEqual(weahterViewController.weatherImageView.image, R.image.cloudy())
weatherViewController.loadWeather(self)
await Task.yield()

XCTAssertEqual(weatherViewController.weatherImageView.tintColor, R.color.gray())
XCTAssertEqual(weatherViewController.weatherImageView.image, R.image.cloudy())
}

func test_天気予報がrainyだったらImageViewのImageにrainyが設定されること_TintColorがblueに設定されること() throws {
weahterModel.fetchWeatherImpl = { _ in
Response(weather: .rainy, maxTemp: 0, minTemp: 0, date: Date())
@MainActor
func test_天気予報がrainyだったらImageViewのImageにrainyが設定されること_TintColorがblueに設定されること() async throws {
weatherModel.fetchWeatherImpl = { _ in
let response = Response(weather: .rainy, maxTemp: 0, minTemp: 0, date: Date())
return Result.success(response)
}

weahterViewController.loadWeather()
XCTAssertEqual(weahterViewController.weatherImageView.tintColor, R.color.blue())
XCTAssertEqual(weahterViewController.weatherImageView.image, R.image.rainy())
weatherViewController.loadWeather(self)
await Task.yield()

XCTAssertEqual(weatherViewController.weatherImageView.tintColor, R.color.blue())
XCTAssertEqual(weatherViewController.weatherImageView.image, R.image.rainy())
}

func test_最高気温_最低気温がUILabelに設定されること() throws {
weahterModel.fetchWeatherImpl = { _ in
Response(weather: .rainy, maxTemp: 100, minTemp: -100, date: Date())
@MainActor
func test_最高気温_最低気温がUILabelに設定されること() async throws {
weatherModel.fetchWeatherImpl = { _ in
let response = Response(weather: .rainy, maxTemp: 100, minTemp: -100, date: Date())
return Result.success(response)
}

weahterViewController.loadWeather()
XCTAssertEqual(weahterViewController.minTempLabel.text, "-100")
XCTAssertEqual(weahterViewController.maxTempLabel.text, "100")
weatherViewController.loadWeather(self)
await Task.yield()

XCTAssertEqual(weatherViewController.minTempLabel.text, "-100")
XCTAssertEqual(weatherViewController.maxTempLabel.text, "100")
}
}

class WeatherModelMock: WeatherModel {

var fetchWeatherImpl: ((Request) throws -> Response)!
var fetchWeatherImpl: ((Request) -> Result<Response, WeatherError>)!

func fetchWeather(_ request: Request) throws -> Response {
return try fetchWeatherImpl(request)
func fetchWeather(at area: String, date: Date, completion: @escaping (Result<Response, WeatherError>) -> Void) {
completion(fetchWeatherImpl(Request(area: area, date: date)))
}
}

final class DisasterModelMock: DisasterModel {

func fetchDisaster(completion: ((String) -> Void)?) {
completion?("只今、災害情報はありません。")
}
}
2 changes: 1 addition & 1 deletion Sources/YumemiWeather/YumemiDisaster.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public class YumemiDisaster {
public init() {}

public func fetchDisaster() {
self.delegate?.handle(disaster: "只今、災害情報はありません。")
delegate?.handle(disaster: "只今、災害情報はありません。")
}

}
120 changes: 60 additions & 60 deletions Sources/YumemiWeather/YumemiWeather.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,59 +25,10 @@ public enum YumemiWeatherError: Swift.Error {

final public class YumemiWeather {

static let apiDuration: TimeInterval = 2

private static let dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
return dateFormatter
}()

static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(dateFormatter)
return decoder
}()

static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .formatted(dateFormatter)
return encoder
}()

/// 引数の値でResponse構造体を作成する。引数がnilの場合はランダムに値を作成する。
/// - Parameters:
/// - weatherCondition: 天気状況を表すenum
/// - maxTemperature: 最高気温
/// - minTemperature: 最低気温
/// - date: 日付
/// - seed: シード値
/// - Returns: Response構造体

static func makeRandomResponse(weatherCondition: WeatherCondition? = nil, maxTemperature: Int? = nil, minTemperature: Int? = nil, date: Date? = nil, seed: Int? = nil) -> Response {
return makeRandomResponse(weatherCondition: weatherCondition, maxTemperature: maxTemperature, minTemperature: minTemperature, date: date, seed: seed ?? Int.random(in: Int.min...Int.max))
}

private static func makeRandomResponse(weatherCondition: WeatherCondition?, maxTemperature: Int?, minTemperature: Int?, date: Date?, seed seedValue: Int) -> Response {
var generator = SeedRandomNumberGenerator(seed: seedValue)
let weatherCondition = weatherCondition ?? WeatherCondition.allCases.randomElement(using: &generator)!
let maxTemperature = maxTemperature ?? Int.random(in: 10...40, using: &generator)
let minTemperature = minTemperature ?? Int.random(in: -40..<maxTemperature, using: &generator)
let date = date ?? Date()

return Response(
weatherCondition: weatherCondition.rawValue,
maxTemperature: maxTemperature,
minTemperature: minTemperature,
date: date
)
}

/// 擬似 天気予報 API Simple ver
/// - Returns: 天気状況を表す文字列 "sunny" or "cloudy" or "rainy"
public static func fetchWeatherCondition() -> String {
return self.makeRandomResponse().weatherCondition
return makeRandomResponse().weatherCondition
}

/// 擬似 天気予報 API Throws ver
Expand All @@ -90,7 +41,7 @@ final public class YumemiWeather {
throw YumemiWeatherError.unknownError
}

return self.makeRandomResponse().weatherCondition
return makeRandomResponse().weatherCondition
}

/// 擬似 天気予報 API JSON ver
Expand Down Expand Up @@ -153,13 +104,8 @@ final public class YumemiWeather {
/// - Returns: Weather レスポンスの JSON 文字列
public static func syncFetchWeather(_ jsonString: String) throws -> String {
Thread.sleep(forTimeInterval: apiDuration)
return try self.fetchWeather(jsonString)
return try fetchWeather(jsonString)
}


/// - Throws: YumemiWeatherError パラメータが正常でもランダムにエラーが発生する
/// - Parameter jsonString: 地域と日付を含む JSON 文字列
/// - Returns: Weather レスポンスの JSON 文字列

/// 擬似 天気予報 API Callback ver
///
Expand Down Expand Up @@ -187,10 +133,10 @@ final public class YumemiWeather {
DispatchQueue.global().asyncAfter(deadline: .now() + apiDuration) {
do {
let response = try fetchWeather(jsonString)
completion(Result.success(response))
completion(.success(response))
}
catch let error where error is YumemiWeatherError {
completion(Result.failure(error as! YumemiWeatherError))
catch let error as YumemiWeatherError {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

エラー処理のキャスト処理をコンパクトにしました

Copy link
Contributor

Choose a reason for hiding this comment

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

良好 (good)-badge
上手な型キャストパターンの扱い方です。

completion(.failure(error))
}
catch {
fatalError()
Copy link
Contributor

@yumemi-kumagai yumemi-kumagai Apr 30, 2024

Choose a reason for hiding this comment

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

推奨 (recommend)-badge
せっかくなので、通常は有り得ないとはいえ無言で fatalError するのは控えておくと良いかもしれません。

Suggested change
fatalError()
fatalError("An unexpected error has occurred: \(error.localizedDescription)")

Expand Down Expand Up @@ -228,3 +174,57 @@ final public class YumemiWeather {
}
}
}

extension YumemiWeather {

static let apiDuration: TimeInterval = 2

private static let dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
return dateFormatter
}()

static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(dateFormatter)
return decoder
}()

static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .formatted(dateFormatter)
return encoder
}()

/// 引数の値でResponse構造体を作成する。引数がnilの場合はランダムに値を作成する。
/// - Parameters:
/// - weatherCondition: 天気状況を表すenum
/// - maxTemperature: 最高気温
/// - minTemperature: 最低気温
/// - date: 日付
/// - seed: シード値
/// - Returns: Response構造体

static func makeRandomResponse(
weatherCondition: WeatherCondition? = nil,
maxTemperature: Int? = nil,
minTemperature: Int? = nil,
date: Date? = nil,
seed: Int? = nil
) -> Response {
var generator = SeedRandomNumberGenerator(seed: seed ?? Int.random(in: Int.min...Int.max))
let weatherCondition = weatherCondition ?? WeatherCondition.allCases.randomElement(using: &generator)!
let maxTemperature = maxTemperature ?? Int.random(in: 10...40, using: &generator)
let minTemperature = minTemperature ?? Int.random(in: -40..<maxTemperature, using: &generator)
let date = date ?? Date()

return Response(
weatherCondition: weatherCondition.rawValue,
maxTemperature: maxTemperature,
minTemperature: minTemperature,
date: date
)
}
}
Loading
Loading