diff --git a/Sources/YumemiWeather/YumemiWeather.APIQuality.swift b/Sources/YumemiWeather/YumemiWeather.APIQuality.swift new file mode 100644 index 0000000..2a059f3 --- /dev/null +++ b/Sources/YumemiWeather/YumemiWeather.APIQuality.swift @@ -0,0 +1,54 @@ +// +// YumemiWeather.APIQuality.swift +// +// +// Created by Tomohiro Kumagai on 2024/05/02 +// +// + +extension YumemiWeather { + + /// API の品質を表現します。 + enum APIQuality { + case sometimesFails(probability: Double) + case alwaysFails + case neverFails + } +} + +extension YumemiWeather { + + /// API の想定品質です。 + static var apiQuality: APIQuality = .sometimesFails(probability: 0.25) + + static func whetherHit(with probability: Double) -> Bool { + return (0 ..< probability).contains(.random(in: 0 ..< 1)) + } + + /// この場所に不安定要素を埋め込みます。 + /// + /// 不安定さの度合いは `apiQuality` に依存します。 + /// - Throws: YumemiError ここで失敗が生成されると YumemiWeatherError.unknownError が送出されます。 + static func introduceInstability() throws { + + switch apiQuality { + + case .neverFails: + return + + case .sometimesFails(let probability) where !whetherHit(with: probability): + return + + case .sometimesFails, .alwaysFails: + throw YumemiWeatherError.unknownError + } + } + + /// この場所に不安定要素を埋め込みます。 + /// + /// 不安定さの度合いは `apiQuality` に依存します。 + /// - Throws: YumemiError ここで失敗が生成されると YumemiWeatherError.unknownError が送出されます。 + func introduceInstability() throws { + try Self.introduceInstability() + } +} diff --git a/Sources/YumemiWeather/YumemiWeather.swift b/Sources/YumemiWeather/YumemiWeather.swift index 096eb15..b5719d2 100644 --- a/Sources/YumemiWeather/YumemiWeather.swift +++ b/Sources/YumemiWeather/YumemiWeather.swift @@ -39,28 +39,33 @@ public enum YumemiWeatherError: Error { case unknownError } +/// 天気予報を取得する擬似天気予報 API です。 +/// +/// ゆめみ iOS 研修用の実装であり、実際の天気予報ではなくランダムな天気予報が得られます。 +/// 研修効果を高めるため、さまざまなバージョンの API が用意されていて、 +/// その中には適切なパラメーターを与えたときでも一定の確率でエラーを返すものもあります。 final public class YumemiWeather { - /// 擬似 天気予報 API Simple ver + /// 天気予報を読み込む API の Simple Version です。 /// - Returns: 天気状況を表す文字列 "sunny" or "cloudy" or "rainy" public static func fetchWeatherCondition() -> String { return makeRandomResponse().weatherCondition } - /// 擬似 天気予報 API Throws ver + /// 天気予報を読み込む API の Throwing Version です。 /// - Throws: YumemiWeatherError /// - Parameters: /// - area: 天気予報を取得する対象地域 example: "tokyo" /// - Returns: 天気状況を表す文字列 "sunny" or "cloudy" or "rainy" public static func fetchWeatherCondition(at area: String) throws -> String { - if Int.random(in: 0...4) == 4 { - throw YumemiWeatherError.unknownError - } - - return makeRandomResponse().weatherCondition + try introduceInstability() + return self.makeRandomResponse().weatherCondition } - /// 擬似 天気予報 API JSON ver + /// 天気予報を読み込む API の JSON Version です。 + /// + /// JSON 文字列で地域情報 `area` と日付情報 `date` を持つオブジェクトを受け取って、それに該当する天気予報を取得します。 + /// 取得された天気予報は速やかに返されます。 /// /// API に請求する JSON 文字列の例: /// @@ -90,14 +95,14 @@ final public class YumemiWeather { let response = makeRandomResponse(date: request.date) let responseData = try encoder.encode(response) - if Int.random(in: 0...4) == 4 { - throw YumemiWeatherError.unknownError - } - + try introduceInstability() return String(data: responseData, encoding: .utf8)! } - /// 擬似 天気予報 API Sync ver + /// 天気予報を読み込む API の Sync Version です。 + /// + /// JSON 文字列で地域情報 `area` と日付情報 `date` を持つオブジェクトを受け取って、それに該当する天気予報を取得します。 + /// この API は同期的に実行され、天気予報を返すまでに若干時間がかかります。 /// /// API に請求する JSON 文字列の例: /// @@ -123,7 +128,10 @@ final public class YumemiWeather { return try fetchWeather(jsonString) } - /// 擬似 天気予報 API Callback ver + /// 天気予報を読み込む API の Callback Version です。 + /// + /// JSON 文字列で地域情報 `area` と日付情報 `date` を持つオブジェクトを受け取って、それに該当する天気予報を取得します。 + /// この API は非同期的に実行され、天気予報を取得できるとその結果を添えて `completion` を呼び出します。 /// /// API に請求する JSON 文字列の例: /// @@ -160,8 +168,11 @@ final public class YumemiWeather { } } - /// 擬似 天気予報 API Async ver + /// 天気予報を読み込む API の Async Version です。 /// + /// JSON 文字列で地域情報 `area` と日付情報 `date` を持つオブジェクトを受け取って、それに該当する天気予報を取得します。 + /// この API は非同期的に実行され、天気予報を取得できるまでは Swift Concurrency により処理が中断されます。 + /// /// API に請求する JSON 文字列の例: /// /// { diff --git a/Sources/YumemiWeather/YumemiWeatherList.swift b/Sources/YumemiWeather/YumemiWeatherList.swift index 85d00e6..7833b57 100644 --- a/Sources/YumemiWeather/YumemiWeatherList.swift +++ b/Sources/YumemiWeather/YumemiWeatherList.swift @@ -34,7 +34,12 @@ public enum Area: String, CaseIterable, Codable { public extension YumemiWeather { - /// 擬似 天気予報一覧 API JSON ver + /// 天気予報一覧を読み込む API の JSON Version です。 + /// + /// JSON 文字列で地域情報 `areas` と日付情報 `date` を持つオブジェクトを受け取って、それに該当する天気予報を取得します。 + /// 取得された天気予報は速やかに返されます。 + /// + /// 地域情報は配列で複数の地域を一括指定し、それらの地域の天気予報を取得できます。地域を指定しない場合は全地域の天気予報を取得します。 /// /// API に請求する JSON 文字列の例: /// @@ -42,6 +47,7 @@ public extension YumemiWeather { /// "areas": ["Tokyo"], /// "date": "2020-04-01T12:00:00+09:00" /// } + /// /// 返された AreaResponse の JSON 文字列の例 /// /// [ @@ -64,9 +70,7 @@ public extension YumemiWeather { throw YumemiWeatherError.invalidParameterError } - if Int.random(in: 0...4) == 4 { - throw YumemiWeatherError.unknownError - } + try introduceInstability() let areas = request.areas.isEmpty ? Area.allCases : request.areas.compactMap { Area(rawValue: $0) } let areaResponses = areas.map { area -> AreaResponse in @@ -78,7 +82,12 @@ public extension YumemiWeather { return String(data: responseData, encoding: .utf8)! } - /// 擬似 天気予報一覧 API Sync ver + /// 天気予報一覧を読み込む API の Sync Version です。 + /// + /// JSON 文字列で地域情報 `areas` と日付情報 `date` を持つオブジェクトを受け取って、それに該当する天気予報を取得します。 + /// この API は同期的に実行され、天気予報を返すまでに若干時間がかかります。 + /// + /// 地域情報は配列で複数の地域を一括指定し、それらの地域の天気予報を取得できます。地域を指定しない場合は全地域の天気予報を取得します。 /// /// API に請求する JSON 文字列の例: /// @@ -107,7 +116,12 @@ public extension YumemiWeather { return try fetchWeatherList(jsonString) } - /// 擬似 天気予報一覧 API Callback ver + /// 天気予報一覧を読み込む API の Callback Version です。 + /// + /// JSON 文字列で地域情報 `areas` と日付情報 `date` を持つオブジェクトを受け取って、それに該当する天気予報を取得します。 + /// この API は非同期的に実行され、天気予報を取得できるとその結果を添えて `completion` を呼び出します。 + /// + /// 地域情報は配列で複数の地域を一括指定し、それらの地域の天気予報を取得できます。地域を指定しない場合は全地域の天気予報を取得します。 /// /// API に請求する JSON 文字列の例: /// @@ -149,7 +163,12 @@ public extension YumemiWeather { } } - /// 擬似 天気予報一覧API Async ver + /// 天気予報一覧を読み込む API の Async Version です。 + /// + /// JSON 文字列で地域情報 `areas` と日付情報 `date` を持つオブジェクトを受け取って、それに該当する天気予報を取得します。 + /// この API は非同期的に実行され、天気予報を取得できるまでは Swift Concurrency により処理が中断されます。 + /// + /// 地域情報は配列で複数の地域を一括指定し、それらの地域の天気予報を取得できます。地域を指定しない場合は全地域の天気予報を取得します。 /// /// API に請求する JSON 文字列の例: /// diff --git a/Tests/YumemiWeatherTests/TestMethods.swift b/Tests/YumemiWeatherTests/TestMethods.swift new file mode 100644 index 0000000..005e8bf --- /dev/null +++ b/Tests/YumemiWeatherTests/TestMethods.swift @@ -0,0 +1,67 @@ +// +// TestMethods.swift +// +// +// Created by Tomohiro Kumagai on 2024/05/02 +// +// + +import XCTest +@testable import YumemiWeather + +/// 失敗数が想定内かを判定します。 +/// - Parameters: +/// - probability: 失敗率です。 +/// - tryingCount: 総試行回数です。 +/// - failureCount: うち、失敗した回数です。 +/// - file: 判定コードが記載されたファイルです。 +/// - line: 判定コードが記載された行です。 +func XCTAssertFailureCount(probability: Double, tryingCount: Int, failureCount: Int, file: StaticString = #filePath, line: UInt = #line) { + + let estimatedFailureCount = Double(tryingCount) * probability + let expectedFailureMargin = Double(tryingCount) * 0.01 + + let expectedFailureRange = estimatedFailureCount - expectedFailureMargin ..< estimatedFailureCount + expectedFailureMargin + + XCTAssertTrue(expectedFailureRange.contains(Double(failureCount)), "Failures: \(failureCount), Estimated failures = \(estimatedFailureCount)±\(expectedFailureMargin)", file: file, line: line) +} + +/// YumemiWether API の品質をテストします。 +/// - Parameters: +/// - tryingCount: 総試行回数です。 +/// - quality: API の想定品質です。 +/// - file: 判定コードが記載されたファイルです。 +/// - line: 判定コードが記載された行です。 +func XCTAssertAPIQuality(_ predicate: () throws -> Void, tryingCount: Int, quality: YumemiWeather.APIQuality, file: StaticString = #filePath, line: UInt = #line) { + + var succeededCount = 0 + var failedCount = 0 + + YumemiWeather.apiQuality = quality + + for _ in 0 ..< tryingCount { + + switch Result(catching: predicate) { + + case .success(): + succeededCount += 1 + + case .failure(_): + failedCount += 1 + } + } + + switch quality { + + case .neverFails: + XCTAssertEqual(succeededCount, tryingCount, file: file, line: line) + XCTAssertEqual(failedCount, 0, file: file, line: line) + + case .alwaysFails: + XCTAssertEqual(succeededCount, 0, file: file, line: line) + XCTAssertEqual(failedCount, tryingCount, file: file, line: line) + + case .sometimesFails(let probability): + XCTAssertFailureCount(probability: probability, tryingCount: tryingCount, failureCount: failedCount, file: file, line: line) + } +} diff --git a/Tests/YumemiWeatherTests/YumemiWeatherListTests.swift b/Tests/YumemiWeatherTests/YumemiWeatherListTests.swift index 5dce2f7..2fddfab 100644 --- a/Tests/YumemiWeatherTests/YumemiWeatherListTests.swift +++ b/Tests/YumemiWeatherTests/YumemiWeatherListTests.swift @@ -3,6 +3,28 @@ import XCTest final class YumemiWeatherListTests: XCTestCase { + override func setUpWithError() throws { + YumemiWeather.apiQuality = .neverFails + } + + func test_APIは必ずしも成功しない() throws { + + let tryingCount = 15_000 + let request = """ + { + "area": "tokyo", + "date": "2020-04-01T12:00:00+09:00" + } + """ + let predicate = { _ = try YumemiWeather.fetchWeather(request) } + + XCTAssertAPIQuality(predicate, tryingCount: tryingCount, quality: .sometimesFails(probability: 0.25)) + XCTAssertAPIQuality(predicate, tryingCount: tryingCount, quality: .sometimesFails(probability: 0.8)) + XCTAssertAPIQuality(predicate, tryingCount: tryingCount, quality: .sometimesFails(probability: 0.05)) + XCTAssertAPIQuality(predicate, tryingCount: tryingCount, quality: .alwaysFails) + XCTAssertAPIQuality(predicate, tryingCount: tryingCount, quality: .neverFails) + } + func test_Areasに空を指定したときに全ての地域が取得される() throws { let request = """ @@ -24,7 +46,7 @@ final class YumemiWeatherListTests: XCTestCase { XCTAssertEqual(response.map(\.area), Area.allCases) } - func test_fetchWeatherList_jsonString() { + func test_fetchWeatherList_jsonString() throws { let parameter = """ { "areas": [], @@ -49,7 +71,7 @@ final class YumemiWeatherListTests: XCTestCase { } } - func test_fetchWeatherList_jsonString_one() { + func test_fetchWeatherList_jsonString_one() throws { let parameter = """ { "areas": ["Tokyo"], @@ -81,7 +103,7 @@ final class YumemiWeatherListTests: XCTestCase { } } - func test_fetchWeatherList_jsonString_two() { + func test_fetchWeatherList_jsonString_two() throws { let parameter = """ { "areas": ["Tokyo", "Nagoya"], @@ -111,7 +133,7 @@ final class YumemiWeatherListTests: XCTestCase { } } - func test_fetchWeatherList_jsonString_none() { + func test_fetchWeatherList_jsonString_none() throws { let parameter = """ { "areas": ["LosAngeles"], diff --git a/Tests/YumemiWeatherTests/YumemiWeatherTests.swift b/Tests/YumemiWeatherTests/YumemiWeatherTests.swift index 1b46ea3..cd0077d 100644 --- a/Tests/YumemiWeatherTests/YumemiWeatherTests.swift +++ b/Tests/YumemiWeatherTests/YumemiWeatherTests.swift @@ -3,6 +3,10 @@ import XCTest final class YumemiWeatherTests: XCTestCase { + override func setUpWithError() throws { + YumemiWeather.apiQuality = .neverFails + } + func test_ランダムにレスポンスを生成する() { // 日付を省略すると実行した瞬間のものが得られてしまうため、 @@ -62,7 +66,7 @@ final class YumemiWeatherTests: XCTestCase { } } - func test_fetchWeather_jsonString() { + func test_fetchWeather_jsonString() throws { let parameter = """ { "area": "Tokyo", @@ -86,7 +90,7 @@ final class YumemiWeatherTests: XCTestCase { } } - func test_fetchWeather_jsonString_sync() { + func test_fetchWeather_jsonString_sync() throws { let beginDate = Date() let parameter = """ { @@ -139,7 +143,7 @@ final class YumemiWeatherTests: XCTestCase { } @available(iOS 13, macOS 10.15, *) - func test_fetchWeather_jsonString_async() async { + func test_fetchWeather_jsonString_async() async throws { let beginDate = Date() let parameter = """ {