天気アプリやセンサーアプリで「摂氏十度の上昇」が「華氏五十度の上昇」と表示される場合、原因は多くの場合、書式設定ではなく、温度差を絶対温度と同じ方法で変換していることにあります。クラウド Mac なら、独立した Swift テストパッケージで計算の契約を固めてから、iOS プロジェクトに組み込めます。センサーを接続する必要も、シミュレータを起動する必要もありません。
温度の測定値と変化量を分ける
絶対温度の変換では、倍率だけでなく原点のずれも考慮します。摂氏零度は華氏三十二度です。一方、温度差で変わるのは倍率だけで、摂氏十度の上昇は華氏十八度の上昇に相当します。Foundation の Measurement<UnitTemperature> は温度の測定値を表す型であり、変数名から変化量かどうかを判断してくれるわけではありません。
モデルでは reading と delta を明確に分け、インターフェースのドキュメントに単位を明記することをお勧めします。ビジネスロジック層では書式設定前の数値を保持し、小数点以下の桁数は表示時に決めます。「二十三・五度」のような画面上の文字列を解析し直して、計算の入力に使わないでください。
温度差は負の値も取れますが、絶対零度を下回る絶対温度は拒否する必要があります。この二つのフィールドに同じ範囲チェックを適用することはできません。
以下の検証は、物理的な温度を対象にしています。数学的な外挿データを扱う製品では、入力の制約を黙って緩めるのではなく、別途その範囲を定義してください。
UI に依存しないテストパッケージを作る
既存プロジェクトの設定を変更しないよう、まず独立したディレクトリでツールチェーンを確認し、パッケージを生成します。これらのコマンドには、利用可能な Xcode コマンドラインツールがインストールされている必要があります。テストには、現在選択されている Swift ツールチェーンが使われます。
xcrun swift --version
workdir="$(mktemp -d)"
cd "$workdir"
swift package init --type library --name TemperatureChecks
生成されたファイルはそのまま残し、次の節のコードを Tests/TemperatureChecksTests/TemperatureContractTests.swift に保存します。依存するのは Foundation と XCTest だけなので、外部パッケージを追加する必要はありません。
この独立パッケージで検証できるのは変換ルールであり、iOS での表示確認まで完了したことにはなりません。プロジェクトに移す際は、テストから本番の実装を呼び出すようにしてください。関数を二重に持つと、それぞれが別々に変更され、テストが古いコピーだけを確認し続けるおそれがあります。
基準値と往復変換でルールを固定する
絶対温度は、まず有限値であることを確認し、次にケルビンへ変換して物理的な下限を確認します。温度差は「変換後の終点から変換後の零点を引く」ことで、原点のずれを取り除きます。この方法は、ここで扱う温度の線形変換に限ったものです。任意の単位モデルにそのまま適用しないでください。
import Foundation
import XCTest
private enum TemperatureError: Error {
case invalidReading
}
private func kelvin(
_ value: Double,
from unit: UnitTemperature
) throws -> Double {
guard value.isFinite else {
throw TemperatureError.invalidReading
}
let result = Measurement(value: value, unit: unit)
.converted(to: .kelvin).value
guard result.isFinite, result >= 0 else {
throw TemperatureError.invalidReading
}
return result
}
private func temperatureDelta(
_ value: Double,
from source: UnitTemperature,
to target: UnitTemperature
) -> Double {
let endpoint = Measurement(value: value, unit: source)
.converted(to: target).value
let origin = Measurement(value: 0, unit: source)
.converted(to: target).value
return endpoint - origin
}
final class TemperatureContractTests: XCTestCase {
func testReferencePoints() throws {
let freezing = Measurement(
value: 0,
unit: UnitTemperature.celsius
).converted(to: .fahrenheit).value
XCTAssertEqual(freezing, 32, accuracy: 1e-9)
XCTAssertEqual(
try kelvin(0, from: .celsius),
273.15,
accuracy: 1e-9
)
XCTAssertEqual(
try kelvin(-273.15, from: .celsius),
0,
accuracy: 1e-9
)
}
func testTemperatureDeltas() {
for value in [-10.0, 0.0, 10.0] {
XCTAssertEqual(
temperatureDelta(
value,
from: .celsius,
to: .fahrenheit
),
value * 1.8,
accuracy: 1e-9
)
}
}
func testRoundTrip() {
for value in [-40.0, 0.0, 37.0, 100.0] {
let original = Measurement(
value: value,
unit: UnitTemperature.celsius
)
let restored = original.converted(to: .fahrenheit)
.converted(to: .celsius)
XCTAssertEqual(
restored.value,
value,
accuracy: 1e-9
)
}
}
func testInvalidReadings() {
for value in [
Double.nan,
Double.infinity,
-Double.infinity,
-274.0
] {
XCTAssertThrowsError(
try kelvin(value, from: .celsius)
)
}
}
}
パッケージのルートディレクトリで swift test を実行します。ここでの許容誤差は浮動小数点演算の誤差を吸収するためのもので、業務上許容する誤差ではありません。センサーの精度は別途定義し、アサーションの許容誤差を広げて不具合を隠さないようにしてください。
往復変換のテストも、基準値の確認の代わりにはなりません。両方向の誤りが打ち消し合うと、往復テストは通ってしまう可能性があります。少なくとも、零点、負の値、既知の基準値、不正な入力の四種類のサンプルを残してください。温度差の入力側でも、非有限値を別途拒否する必要があります。上のヘルパー関数は変換方法だけを示しており、入力検証は担っていません。
ローカライズと数値精度を分けて検証する
次のコードをパッケージディレクトリ内の format-temperature.swift に保存し、swift format-temperature.swift で実行します。ここでは単位を摂氏に固定し、地域設定によって単位が自動変更されないようにすることで、表示ルールだけを確認します。
import Foundation
for identifier in ["zh_CN", "en_US", "fr_FR"] {
let locale = Locale(identifier: identifier)
let formatter = MeasurementFormatter()
formatter.locale = locale
formatter.unitOptions = .providedUnit
formatter.unitStyle = .short
formatter.numberFormatter.locale = locale
formatter.numberFormatter.minimumFractionDigits = 1
formatter.numberFormatter.maximumFractionDigits = 1
let reading = Measurement(
value: 23.45,
unit: UnitTemperature.celsius
)
print(identifier, formatter.string(from: reading))
}
小数点の区切り文字、単位記号、空白を確認します。ただし、OS のバージョンが異なっても文字列全体が一字一句同じになるとは限りません。スナップショットによるアサーションが必要な場合は、OS のバージョンとロケール識別子も併せて記録してください。
温度差の表示を、絶対温度用フォーマッタの暗黙的な処理に任せてはいけません。まず目的の単位で温度差を計算し、その後で数値フォーマッタとローカライズリソースを使い、「温度差」であることを明示します。負のゼロにも製品としてのルールが必要です。丸めた結果がゼロになる場合にマイナス記号を取り除くかどうかは、表示層で統一して扱ってください。
プロジェクトへの組み込みで守る三つの境界
第一に、元データには必ず単位を持たせます。不明な単位を摂氏と見なしてはいけません。誤ったデータでも、もっともらしい範囲に収まる可能性があるためです。プロトコルに未知の列挙値が含まれていた場合は、明示的なエラーを返してください。
第二に、しきい値との比較は、単位を統一した丸め前の数値で行います。同じ数値に見える二つの測定値でも、警報の判定結果が同じとは限りません。「数値は異なるが表示は同じ」というサンプルを追加しておけば、UI のリファクタリングがビジネスロジックの判定に影響するのを防げます。
第三に、クラウド Mac 上で Foundation のテストが通った後も、対象の iOS 実行環境で書式とレイアウトを確認する必要があります。ウィジェット、グラフの軸ラベル、音声読み上げはそれぞれ個別に検証し、メイン画面だけの確認で済ませないでください。
最後に、テスト結果、ツールチェーンのバージョン、書式設定後の出力を保存します。リグレッションが発生したら、まず変換、入力契約、表示のどこに変化があったのかを切り分け、該当する層を修正してください。画面上の文字列に対する一つのアサーションだけで、すべての正しさを保証しようとしてはいけません。
よくある質問
温度差を絶対温度と同じ方法で換算できないのはなぜですか?
絶対温度の換算には原点のずれが含まれるためです。温度差の値と元の単位のゼロをそれぞれ換算して差を取ります。摂氏十度の温度差は華氏十八度の温度差です。
ローカライズされた温度表示は文字列全体で比較すべきですか?
システムのバージョン、地域、単位、精度を固定した場合に限ります。通常は数値の検証を先に行い、表示層で丸め、単位、レイアウトを別に確認します。
次のビルドは専用クラウドMacで
MacFTPでは、macOSをネイティブ実行するApple Silicon搭載の専用物理マシンをご利用いただけます。日・週・月・四半期からレンタル期間をお選びください。まず構成を確認し、お使いのツールチェーンをノードに接続します。実際の稼働状況はコントロールパネルにリアルタイムで表示されます。