云端 Mac 上的 iOS 列表排序契约与回归测试

云端 Mac 上的 iOS 列表排序契约与回归测试

文件列表里有“报告2”和“报告10”,刷新后两个同名项目又交换位置。问题未必出在界面刷新,也可能是比较器没有定义完整顺序。在云端 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
}

不要在比较过程中读取网络状态、修改计数器或查询会变化的用户设置。一次排序会多次比较同一对象,结果必须一致。也不要用“小于等于”实现比较器:对象不能排在自身之前。

名称比较等价不代表业务对象相同。“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
        )
    }
}

第一项测试验收数字顺序和同名项。第二项检查反自反性、非对称性与传递性,能抓住临时拼接多种规则造成的矛盾。第三项改变输入顺序,验证结果不依赖接收顺序。

三重循环适合十几个代表性样本,不要直接套在完整生产列表上。大数据集应另测排序耗时,不能把性质检查误当性能基准。

补齐 Unicode 与空值边界

继续加入空名称、前导零、中文名称、组合重音和表情序列。预组合字符与“字母加组合重音”可能显示相同;不能用字符串长度判断它们的显示顺序。

对“文件02”和“文件2”,不要擅自假设数字比较后的结果。先明确产品是否把它们视为同组,再在选定运行时中验证。如果必须区分前导零,可在名称等价后增加明确的次级名称规则,最后仍以业务标识收口。

缺失名称要在模型转换阶段统一处理,例如转换为空串,或定义“缺失项置后”的分支。不要在比较器中随机补默认值。降序也应重新定义键方向,不能简单写成升序结果取反;取反会让相等对象被判断为“小于”。

在云端运行并记录失败证据

在云端 Mac 的测试任务中固定 Xcode、模拟器运行时和比较区域,用已有的单元测试入口执行这些测试。保存提交标识、工具链版本、失败输入和实际输出标识序列,不需要记录真实客户文件名。

先验收纯函数,再验收界面:连续刷新两次,确认同名项目不交换位置;删除再插入一项,确认其余项目的相对顺序符合规则。界面层必须复用同一个比较器,不能另写一套排序。

固定区域并不保证系统整理规则永远不变。升级运行时后,复核代表性语言样本;若差异来自系统规则,决定接受变更还是采用自定义排序键,而不是直接更新全部快照。

最终交付应包含排序契约、代表性样本、比较器性质检查和刷新验收结果。这样,下次顺序变化时能判断是数据、规则还是运行时发生了变化,而不是再次从截图开始猜。

常见问题

排序算法稳定,是否就能避免同名项目跳动?

不能。稳定排序保留的是输入中的相对顺序;如果服务端返回顺序变化,同名项仍会变化。需要用持久且唯一的业务标识作为最终排序键。

固定比较区域后,是否可以永久复用同一份排序快照?

不宜。固定区域消除了用户设置带来的变量,但系统字符串整理规则仍可能随运行时变化。保留契约性质测试,并在升级运行时时复核代表性样本。

为下一次构建订一台云端 Mac

选好机型、城市和租期,使用独享物理节点运行自己的开发工具。可用状态以控制台实时返回为准。

把阅读变成下一次构建

先核对机型与租期,再安排项目。