クラウド Mac で iOS リストの並び順を検証する

クラウド Mac で iOS リストの並び順を検証する

クラウド Mac で iOS リストの並び順を検証する

ファイル一覧に「レポート2」と「レポート10」があり、更新すると同名の2つの項目がまた入れ替わる。原因は画面の更新処理とは限りません。比較関数が順序を完全に定義できていない可能性もあります。クラウド Mac で iOS のテストを実行するなら、並び替え処理をビューから切り離し、まず少数のサンプルで規則を検証しましょう。画面録画を見て原因を推測するより、問題を再現しやすくなります。

まず並び替えの契約を明確にする

この記事では、ファイル一覧に次の規則を適用します。名前の昇順とし、数字は自然順で比較し、大文字・小文字とアクセントの違いは無視します。名前の比較結果が等しい場合は、永続的な業務上の識別子の昇順で決めます。比較に使うロケールは固定し、マシンの設定によってテスト結果が変わらないようにします。

これは、どの製品にも当てはまる既定の正解ではありません。ユーザー向けの連絡先一覧では、現在の言語に合わせた並び順が必要かもしれません。一方、複数のデバイスで共有するファイル一覧では、一貫した順序が求められることがあります。先に要件を決めてから実装し、「見た目が自然」を合格基準にしないでください。

安定した識別子は、更新の前後で変わってはいけません。配列のインデックス、受信時刻、作成のたびに生成する新しい UUID は、同名項目の順序を決めるキーには使えません。また、データの取り込み時には識別子の重複を拒否する必要があります。重複を許すと、最後のソートキーでも項目を区別できません。

安定ソートが保持できるのは、入力内で同じ値を持つ項目の相対順序だけです。サーバーが毎回異なる順序で返す問題は解決できません。

比較関数を独立した純粋関数にする

次のファイルをアプリのターゲットに追加します。この例では en_US_POSIX を固定で使用しています。ここでは固定の比較方針を選んだという意味であり、あらゆる言語の名前表示に適しているという意味ではありません。

import Foundation

struct ListEntry {
    let id: String
    let name: String
}

func entryLess(_ lhs: ListEntry, _ rhs: ListEntry) -> Bool {
    let order = lhs.name.compare(
        rhs.name,
        options: [.numeric, .caseInsensitive, .diacriticInsensitive],
        range: nil,
        locale: Locale(identifier: "en_US_POSIX")
    )
    if order != .orderedSame {
        return order == .orderedAscending
    }
    return lhs.id < rhs.id
}

比較の途中でネットワークの状態を読み取ったり、カウンターを変更したり、変化しうるユーザー設定を参照したりしないでください。1回のソートで同じオブジェクトが何度も比較されるため、結果は常に一貫している必要があります。また、比較関数を「以下」で実装してはいけません。オブジェクトが自分自身より前に並ぶことはありません。

名前の比較結果が等しいからといって、業務上も同じオブジェクトとは限りません。現在の方針では「Resume」と「résumé」が同じグループに入る可能性がありますが、それを理由にレコードを統合してはいけません。並び替え、検索、重複排除は、それぞれ別の契約です。

少数のサンプルで重要な挙動を固定する

ユニットテストのファイルでアプリのモジュールをインポートし、次のテストクラスを追加します。コードは、上記の型にアクセスできるテストターゲットに配置してください。ListSortingTests のサンプルを、自分のファイル名で拡充していけばよいでしょう。

import XCTest

final class ListSortingTests: XCTestCase {
    let items = [
        ListEntry(id: "d", name: "Report10"),
        ListEntry(id: "b", name: "report2"),
        ListEntry(id: "a", name: "REPORT2"),
        ListEntry(id: "c", name: "Report1")
    ]

    func testNaturalOrderAndTies() {
        XCTAssertEqual(
            items.sorted(by: entryLess).map(\.id),
            ["c", "a", "b", "d"]
        )
    }

    func testComparatorProperties() {
        for a in items {
            XCTAssertFalse(entryLess(a, a))
            for b in items {
                if entryLess(a, b) {
                    XCTAssertFalse(entryLess(b, a))
                }
                for c in items {
                    if entryLess(a, b) && entryLess(b, c) {
                        XCTAssertTrue(entryLess(a, c))
                    }
                }
            }
        }
    }

    func testInputOrderDoesNotChangeOutput() {
        let expected = items.sorted(by: entryLess).map(\.id)
        XCTAssertEqual(
            Array(items.reversed()).sorted(by: entryLess).map(\.id),
            expected
        )
        let rotated = Array(items.dropFirst()) + [items[0]]
        XCTAssertEqual(
            rotated.sorted(by: entryLess).map(\.id),
            expected
        )
    }
}

最初のテストでは、数字の順序と同名項目の扱いを検証します。2つ目は非反射性、非対称性、推移性を確認し、複数の規則をその場しのぎで組み合わせたときに生じる矛盾を検出します。3つ目は入力順を変え、結果が受信順に依存しないことを検証します。

三重ループは、十数件の代表的なサンプルに適しています。本番の一覧全体にそのまま適用しないでください。大規模なデータセットでは、ソートにかかる時間を別途測定する必要があります。性質の検証を性能ベンチマークと混同してはいけません。

Unicode と欠損値の境界ケースを補う

さらに、空の名前、先頭にゼロを含む名前、中国語の名前、結合アクセント、絵文字のシーケンスを追加します。合成済みの文字と「文字+結合アクセント」は、同じように表示されることがあります。文字列の長さで表示上の順序を判断してはいけません。

「ファイル02」と「ファイル2」について、数値比較の結果を決めつけないでください。まず製品として同じグループに扱うかどうかを明確にし、そのうえで選んだランタイム上で検証します。先頭のゼロを区別する必要がある場合は、名前の比較結果が等しくなった後に、名前に対する明示的な第2の比較規則を追加できます。その場合も、最後は業務上の識別子で順序を確定させます。

名前の欠損は、モデルへの変換段階で統一的に処理します。たとえば空文字列に変換するか、「欠損した項目を末尾に置く」分岐を定義します。比較関数の中で、その都度適当な既定値を補わないでください。降順にする場合もキーの向きを定義し直す必要があります。昇順の比較結果を単純に反転すると、等しいオブジェクトが「より小さい」と判定されてしまいます。

クラウドで実行し、失敗の証拠を記録する

クラウド Mac のテストジョブでは、Xcode、シミュレーターのランタイム、比較に使うロケールを固定し、既存のユニットテスト実行経路でこれらのテストを実行します。コミット識別子、ツールチェーンのバージョン、失敗した入力、実際に出力された識別子の並びを保存してください。実際の顧客のファイル名を記録する必要はありません。

まず純粋関数を検証し、その後で画面を検証します。連続して2回更新し、同名項目の位置が入れ替わらないことを確認します。また、1つの項目を削除してから再挿入し、残りの項目の相対順序が規則に従っていることを確認します。UI 層でも同じ比較関数を再利用し、別の並び替え処理を書かないでください。

ロケールを固定しても、システムの照合規則が将来にわたって変わらない保証はありません。ランタイムを更新したら、代表的な言語のサンプルを再確認します。差異がシステムの規則に由来する場合は、すべてのスナップショットをそのまま更新するのではなく、その変更を受け入れるか、独自のソートキーを採用するかを判断してください。

最終成果物には、並び替えの契約、代表的なサンプル、比較関数の性質の検証、更新時の受け入れテスト結果を含めます。そうすれば、次に順序が変わったとき、データ、規則、ランタイムのどれが変化したのかを判断でき、再びスクリーンショットから推測を始めずに済みます。

よくある質問

安定ソートだけで同名項目の順序を固定できますか?

できません。安定ソートは入力における同値項目の相対順序を保ちます。サーバーの返却順が変わる場合は、永続的で一意な業務識別子を最後の比較キーにします。

比較ロケールを固定すればソート結果のスナップショットは永久に使えますか?

システムの文字列比較規則はランタイム更新で変わる可能性があります。更新時に代表的な入力例と比較関数の性質を再確認してください。

次のビルドに向けてクラウド Mac を予約

機種、ロケーション、利用期間を選び、専有物理サーバーでお使いの開発ツールを実行できます。利用可能状況はコントロールパネルに表示される最新情報をご確認ください。

次のビルドにつなげる

機種と利用期間を確認して、プロジェクトの準備を始めましょう。