在云端 Mac 上验收 iOS CSV 导出:从字段转义到危险值拦截

在云端 Mac 上验收 iOS CSV 导出:从字段转义到危险值拦截

团队把 iOS 应用里的记录导出为 CSV,放到远程构建节点执行测试。单元测试显示“文件写入成功”,同事打开文件却发现备注被拆成两行,另一条用户输入变成了电子表格公式。这里要验收的不是分享按钮,而是导出的字节、字段边界和危险输入的处理结果。云端 Mac 适合把这组不依赖人工点击的检查固定在每次构建中。

先写清导出契约

本文采用一个明确的文本契约:输出 UTF-8、无字节顺序标记;每个字段都用双引号包围,字段内的双引号写成两个;记录之间及文件末尾使用 CRLF。字段内部的换行保留原样,不能先把整份 CSV 按换行符拆开再数记录。

另设一条安全规则:自由输入字段去掉首尾空白及控制字符后,若以 =、+、- 或 @ 起始,导出报错。这样会拒绝某些本可作为普通文本的负数;若产品确实需要数值列,应先按列定义解析、校验数值,而不是放宽所有文本列。表头也走同一编码函数,避免出现两套转义逻辑。

CSV 引号解决的是字段分隔,不是电子表格的公式解释。给文本加一个前导单引号在不同导入器中未必得到相同结果;拒绝危险输入更容易定义和测试,但仍需核对目标导入器的行为。

用一个函数生成可检查的字节

把编码逻辑放在应用与测试目标都能引用的 Swift 文件中。以下函数接收二维字符串;实际应用可先把业务模型转换成指定顺序的列。trimmingCharacters 只用于风险探测,输出时仍保留原值,不会悄悄改掉用户数据。

import Foundation

enum CSVExportError: Error {
    case unsafeValue
}

func csvField(_ value: String) throws -> String {
    let ignored = CharacterSet.whitespacesAndNewlines
        .union(.controlCharacters)
    let probe = value.trimmingCharacters(in: ignored)

    if let first = probe.first, "=+-@".contains(first) {
        throw CSVExportError.unsafeValue
    }

    let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
    return "\"" + escaped + "\""
}

func csvDocument(_ rows: [[String]]) throws -> Data {
    let lines = try rows.map { row in
        try row.map(csvField).joined(separator: ",")
    }
    return Data((lines.joined(separator: "\r\n") + "\r\n").utf8)
}

调用方还应检查每行列数是否符合表头,决定空数据集是只导出表头还是提示用户,并将错误转换成可见提示。不要捕获 unsafeValue 后改为输出未经检查的原文;那会让测试通过,真实分享文件却绕过规则。若导出内容包含敏感资料,也应在分享前确认目标文件及其访问权限。

把正常路径和拒绝路径一起测

测试要比较实际输出,不只断言 Data 非空。下面的第一例覆盖逗号、引号、中文和字段内换行;第二例确认前导制表符不能绕过检查。

import XCTest

final class CSVExportTests: XCTestCase {
    func testQuotingAndLineEndings() throws {
        let data = try csvDocument([
            ["name", "note"],
            ["张三", "甲,\"乙\"\n丙"]
        ])

        XCTAssertEqual(
            String(decoding: data, as: UTF8.self),
            "\"name\",\"note\"\r\n\"张三\",\"甲,\"\"乙\"\"\n丙\"\r\n"
        )
    }

    func testFormulaLikeTextIsRejected() {
        for value in ["=1+1", "\t@SUM(A1:A2)", "+1", " -2"] {
            XCTAssertThrowsError(try csvDocument([[value]]))
        }
    }
}

这组测试证明了约定的输出格式和拒绝策略,并不证明每种电子表格都会按预期导入。尤其是引号内的换行、日期外观的文本以及非 ASCII 字符,仍要用实际交付对象所用的导入方式验收。

在构建节点上留下可复核的结果

先在云端 Mac 确认 Xcode 命令行工具可用,再用项目中已共享的 scheme 运行测试。将示例名称替换为仓库中的真实名称;运行前可用 xcodebuild -list -project App.xcodeproj 核对入口。测试目标应能直接访问编码文件,不要为测试另复制一份实现。

xcodebuild test \
  -project App.xcodeproj \
  -scheme App \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -only-testing:AppTests/CSVExportTests \
  -resultBundlePath "$PWD/CSVExport.xcresult"

模拟器名称需与该节点已安装的运行时匹配,可先运行 xcrun simctl list devices available 检查。CSVExport.xcresult 路径在执行前不得已存在;重复运行时为每次任务指定独立目录,避免旧结果干扰。本测试只验证纯 Swift 编码,不依赖图形界面。若另要验收分享流程,应为界面测试单独准备会话和权限条件。

从测试数据走到真实文件

单元测试通过后,用应用的实际导出路径生成一份固定样本,至少包含空字段、逗号、双引号、字段内换行和中文。检查文件是否确由同一个 csvDocument 生成,而不是分享层再次拼接字符串。保存到应用可写的位置时,可用 Data.write(to:options: .atomic) 避免读到写入一半的文件;分享失败后的临时文件如何回收,也应按应用流程测试。

不要只在终端用“行数”判断成功:引号内本来就可能有换行。更稳妥的是对照样本逐字段导入,并检查列顺序、末行及错误提示。若导入器擅自把编号转成数字或把日期文本改成日期,那是另一项格式契约,需要通过明确的列类型或专用交换格式解决,不能假装 CSV 引号会锁住单元格类型。

合并前的检查清单

CSV 导出最难发现的问题,往往不在正常示例,而在一条来自真实用户的备注。把边界输入先变成固定测试,再检查最终文件,远比文件生成后才凭肉眼排错可靠。

常见问题

CSV 字段加上双引号后,还要处理字段内部的双引号吗?

要。字段内部的每个双引号都应写成两个双引号,再用一对双引号包住整个字段;逗号和字段内换行则保留在引号内。

给危险字段前加单引号,就能保证所有电子表格都安全打开吗?

不能保证。不同导入器对单引号的处理可能不同;本文示例选择拒绝以公式触发字符开头的文本,并要求在目标导入器中另行验收。

UDPMac 云端 Mac

把下一次 Xcode 构建放到独享物理机上。

选择 Apple Silicon 机型、节点和租期;通过远程连接运行自己的构建流程。实际可用条件以订购时控制台返回为准。

查看云端 Mac 租用方案