云端 Mac 上的 iOS 浮点数据边界测试:NaN、无穷值与容差

云端 Mac 上的 iOS 浮点数据边界测试:NaN、无穷值与容差

进度条偶尔不刷新,统计图突然出现空白,问题可能不是界面线程,而是上游传入了 NaN。把传感器读数除以量程、把完成量除以总量,都可能产生非法结果。在 ArmShell 云端 Mac 上,可以先把这层计算拆成独立 Swift 包,用边界测试挡住异常,再接入 iOS 工程。

先写数值契约,而不是先调容差

本文只处理一种明确业务:把非负读数归一化到零至一。输入必须有限,量程必须大于零,结果必须有限且不越界;非法数据返回错误,不自动截断。零读数合法,负零统一输出为正零。

这里有一个容易漏掉的条件:value < 0 || value > 1 不能排除 NaN。NaN 的有序比较都返回 false,程序可能继续把它当作有效结果。

有限性、业务范围、近似相等是三层不同的检查。扩大测试容差,不能修复非法输入。

先明确错误处理策略也很重要:界面可以显示“数据不可用”,但不能把失败偷偷转换为零,否则用户无法区分真实零值与读取失败。

建立不依赖模拟器的测试包

先确认命令行工具可用,再在临时目录创建包。以下操作不修改应用工程,也不需要启动图形会话:

xcrun swift --version
root="$(mktemp -d)"
cd "$root"
swift package init --type library --name NumericBoundary

将 Sources/NumericBoundary/NumericBoundary.swift 的内容替换为:

public enum LevelError: Error, Equatable {
    case invalidInput
    case invalidResult
}

public func normalizedLevel(
    _ raw: Double,
    scale: Double
) throws -> Double {
    guard raw.isFinite, scale.isFinite,
          raw >= 0, scale > 0 else {
        throw LevelError.invalidInput
    }

    let ratio = raw / scale
    guard ratio.isFinite, ratio >= 0, ratio <= 1 else {
        throw LevelError.invalidResult
    }

    return ratio == 0 ? 0.0 : ratio
}

不能只验证输入。两个有限数相除,仍可能得到无穷值,例如极大的分子除以极小的正分母。另一方面,超出量程的有限结果也应失败,不能依赖界面组件替计算层兜底。

这段实现允许正常浮点舍入;若业务还要求最小有效读数,需要另设阈值。极小正数相除可能下溢为零,本例接受它,不把“有限”误写成“完全无精度损失”。

用反例覆盖异常路径

将默认测试文件内容替换为以下代码。每个案例都检查错误类别,避免函数因其他原因抛错时误判通过:

import XCTest
@testable import NumericBoundary

final class NumericBoundaryTests: XCTestCase {
    func testInvalidInputs() {
        let cases: [(Double, Double)] = [
            (.nan, 1),
            (.infinity, 1),
            (-.infinity, 1),
            (1, .nan),
            (1, .infinity),
            (1, -.infinity),
            (-1, 1),
            (1, 0),
            (1, -0.0),
            (1, -1)
        ]

        for (raw, scale) in cases {
            XCTAssertThrowsError(
                try normalizedLevel(raw, scale: scale)
            ) { error in
                XCTAssertEqual(error as? LevelError, .invalidInput)
            }
        }
    }

    func testInvalidResults() {
        let cases: [(Double, Double)] = [
            (2, 1),
            (.greatestFiniteMagnitude, .leastNormalMagnitude)
        ]

        for (raw, scale) in cases {
            XCTAssertThrowsError(
                try normalizedLevel(raw, scale: scale)
            ) { error in
                XCTAssertEqual(error as? LevelError, .invalidResult)
            }
        }
    }

    func testValidBoundaries() throws {
        XCTAssertEqual(try normalizedLevel(0, scale: 1), 0)
        XCTAssertEqual(try normalizedLevel(1, scale: 1), 1)
        XCTAssertEqual(
            try normalizedLevel(1, scale: 10),
            0.1,
            accuracy: 1e-12
        )

        let zero = try normalizedLevel(-0.0, scale: 1)
        XCTAssertEqual(zero, 0)
        XCTAssertEqual(zero.sign, .plus)
    }
}

负零在普通相等比较中等于正零,因此必须额外检查 sign,才能证明归一化策略确实生效。异常案例也不要用“结果不等于 NaN”断言:NaN 连自身都不相等,这种测试没有证明力。

容差必须跟业务尺度绑定

示例输出范围固定为零至一,使用绝对误差比较比较直观。1e-12 是这段小计算的测试选择,不是所有传感器、图形或统计任务的通用精度标准。

如果计算值跨度很大,应先确认两个值都有限,再按业务采用绝对误差与相对误差的组合:

abs(actual - expected)
    <= max(absoluteTolerance,
           relativeTolerance * max(abs(actual), abs(expected)))

两个容差本身也应有限且非负。对极端量级,差值或右侧乘法可能溢出,不能把公式直接视作通用安全库。

零附近主要由绝对误差控制,大数附近主要由相对误差控制。测试代码应说明阈值来源,例如设备分辨率或算法误差预算,而不是一直放宽到测试通过。也不要提前格式化成小数字符串再比较,那会掩盖真实差异。

接入流水线并证明门禁有效

在包目录执行:

swift test
swift test -c release

调试和发布配置都要跑。生产校验使用 guard 与抛错,而不是可能在优化构建中被移除的 assert。流水线必须保留命令退出码,测试失败就停止后续交付。

随后做一次破坏验证:临时去掉输入检查中的 raw.isFinite,确认 NaN 案例因错误类别变化而失败,再恢复代码。这样能证明测试真正覆盖了入口,而不只是执行过函数。

接入应用时,应让业务代码调用同一份实现,不要在测试包里维护一个副本。记录工具链版本、提交标识和失败案例即可,不必上传真实用户读数。最终验收标准是:异常输入明确失败、合法边界稳定通过、容差有来源,并且发布配置下仍执行相同契约。

常见问题

只判断数值是否小于零或大于一,能挡住 NaN 吗?

不能。NaN 与零、一进行有序比较时都返回 false,必须先用 isFinite 排除 NaN 和正负无穷,再做范围校验。

输入都是有限数,运算结果还需要检查吗?

需要。有限数相除仍可能溢出为无穷值,运算结果必须再次检查 isFinite,并核对业务允许的范围。

为下一次构建准备云端 Mac

按工作负载选择机型与周期。独享 Apple Silicon 物理节点,非虚拟机。

立即租用云端 Mac

从阅读走到第一次构建

准备好工作负载,再选择适合自己的独享物理节点。