## 为什么选这个切入方式 原计划先引入 SyncSession 值类型再拆文件。但实测 View 层对 @Published 状态的 直接访问约 90+ 处(15 × state、9 × progress、7 × startTime …),那条路要改 20 个属性的读写路径并全量验证。 改为更小的第一步:**抽取无状态解析**。关键区别是这一步**不需要放宽任何 private 访问级别**——Swift 的 private 是文件级作用域,一旦为拆文件而放开内部 状态,封装性就实质下降;而解析器接收「一行字符串」、返回「动作描述」, 天然不依赖 manager 状态。 ## 改动 - 新增 SyncOutputParser.swift(260 行):5 个预编译正则 + stdout/stderr/tqdm 解析 + 缓冲切分,全 static 无状态 - manager 的 4 个方法改为「调用 parser + switch 应用动作」 - 删除 manager 内 5 个正则声明(避免同一模式两处维护) - 主文件 842 → 729 行 ## 关键设计:返回动作序列而非直接改状态 原 parseStdoutLine 用 matched 标记而非提前 return,因此**一行可命中多个模式** (如 `✅ [x] 完成 成功: 3` 同时产出两条日志)。改成 guard-else-return 会静默 改变行为。故 parser 返回 [StdoutAction],manager 逐条应用。 用例 testMultiplePatternsInOneLine 锁定该语义。 ## 顺带修掉一个真实缺陷 原实现部分正则用 NSRange(location: 0, length: trimmed.count) 构造范围。 NSRange 按 **UTF-16 单元**计数,String.count 按**字素簇**计数: 含 emoji 的行(⚠️ 占 2 个 UTF-16 单元)长度偏小 → 行尾的「成功: N」「速率: X」 落在范围外被**静默漏匹配**。 统一改为 (s as NSString).length,并写回归用例 testEmojiLineStillMatchesTrailingRegex(先断言该行确实存在长度差, 否则用例本身没有意义)。 ## 还原一处既有行为 `daily sync: 45%` 的表名取到 "sync" 而非 "daily"——原实现取冒号前最后一个 非数字片段。测试首次失败暴露这点,是测试期望写错、不是代码错。 已在用例中锁定并注明,避免后人"顺手修正"改变既有显示。 ## 测试 Swift 18 → 42 tests(新增 24 条:模式识别、tqdm 提取、stderr 分级、 emoji/NSRange 回归、缓冲切分边界)。全量 8 套件 PASS。
214 lines
9.0 KiB
Swift
214 lines
9.0 KiB
Swift
import XCTest
|
||
@testable import FinDataHub
|
||
|
||
/// `SyncOutputParser` 单元测试。
|
||
///
|
||
/// 这些用例在重构前**根本写不出来**——解析逻辑内嵌在 `PythonProcessManager` 里,
|
||
/// 是 `private` 方法且直接读写 `@Published` 状态,只能靠跑真实同步观察。
|
||
/// 抽成无状态类型后首次可测。
|
||
///
|
||
/// 重点覆盖三件事:
|
||
/// 1. 各模式识别的原有语义(含"一行可命中多个模式"这一点,它靠 `matched`
|
||
/// 标记而非提前 return 实现,很容易在重构中被"优化"掉);
|
||
/// 2. tqdm 数值提取;
|
||
/// 3. **emoji 与 NSRange 的坑**——原实现部分正则用 `String.count` 构造
|
||
/// `NSRange`,含 emoji 的行会因 UTF-16 长度偏小而漏匹配。
|
||
final class SyncOutputParserTests: XCTestCase {
|
||
|
||
// MARK: - stdout:各模式
|
||
|
||
func testGroupHeaderParsed() {
|
||
let actions = SyncOutputParser.actions(forStdoutLine: "📡 [daily] → 6 张表: stock_daily, ...")
|
||
XCTAssertEqual(actions.first, .setGroup(name: "daily", tableTotal: 6))
|
||
// 分组头同时记一条 info 日志
|
||
XCTAssertTrue(actions.contains(.log(level: .info, message: "📡 [daily] → 6 张表: stock_daily, ...")))
|
||
}
|
||
|
||
func testGroupHeaderWithoutTableCount() {
|
||
// 没有 "→ N 张表" 时 tableTotal 为 nil(应用侧回落为 0)
|
||
let actions = SyncOutputParser.actions(forStdoutLine: "📡 [weekly] 开始")
|
||
// 该行不含「张表」,因此不会产出 setGroup
|
||
XCTAssertEqual(actions, [.plainAppend("📡 [weekly] 开始")])
|
||
}
|
||
|
||
func testGroupFinished() {
|
||
let actions = SyncOutputParser.actions(forStdoutLine: "✅ [daily] 完成")
|
||
XCTAssertTrue(actions.contains(.markGroupFinished))
|
||
}
|
||
|
||
func testRowCompleted() {
|
||
let line = "stock_daily 完成: 5528 行 ✓"
|
||
let actions = SyncOutputParser.actions(forStdoutLine: line)
|
||
XCTAssertTrue(actions.contains(.rowCompleted(message: line)))
|
||
}
|
||
|
||
func testDiagnosticLines() {
|
||
for line in ["⚠️ 获取股票列表失败", "📅 增量同步 20260101", "全量同步", "数据已是最新", "跳过同步"] {
|
||
let actions = SyncOutputParser.actions(forStdoutLine: line)
|
||
XCTAssertTrue(
|
||
actions.contains(.log(level: .info, message: line)),
|
||
"诊断行应被识别为 info 日志:\(line)"
|
||
)
|
||
}
|
||
}
|
||
|
||
func testErrorLine() {
|
||
let line = "❌ 同步失败"
|
||
XCTAssertTrue(
|
||
SyncOutputParser.actions(forStdoutLine: line).contains(.log(level: .error, message: line))
|
||
)
|
||
}
|
||
|
||
func testUnmatchedLineIsAppended() {
|
||
let actions = SyncOutputParser.actions(forStdoutLine: "随便一行普通输出")
|
||
XCTAssertEqual(actions, [.plainAppend("随便一行普通输出")])
|
||
}
|
||
|
||
func testEmptyLineProducesNoAction() {
|
||
XCTAssertEqual(SyncOutputParser.actions(forStdoutLine: " \n "), [])
|
||
}
|
||
|
||
/// 关键回归:一行可以命中多个模式。
|
||
///
|
||
/// 原实现用 `matched` 标记而非提前 return,因此 `✅ [x] 完成 成功: 3`
|
||
/// 会同时产出 markGroupFinished 与 log 两条动作。
|
||
/// 若重构时改成 `guard ... else return`,这里会少一条——行为静默改变。
|
||
func testMultiplePatternsInOneLine() {
|
||
let line = "✅ [daily] 完成 成功: 3"
|
||
let actions = SyncOutputParser.actions(forStdoutLine: line)
|
||
XCTAssertTrue(actions.contains(.markGroupFinished))
|
||
XCTAssertTrue(actions.contains(.log(level: .info, message: line)))
|
||
XCTAssertGreaterThanOrEqual(actions.count, 2, "同一行应产出多条动作,不能提前 return")
|
||
}
|
||
|
||
// MARK: - tqdm
|
||
|
||
func testTqdmFullLine() throws {
|
||
let line = "daily sync: 45%|████▌ | 2500/5528 [15:23<18:45, 2.69stock/s]"
|
||
let update = try XCTUnwrap(SyncOutputParser.parseTqdm(line))
|
||
XCTAssertEqual(try XCTUnwrap(update.percent), 0.45, accuracy: 0.0001)
|
||
XCTAssertEqual(update.completed, 2500)
|
||
XCTAssertEqual(update.total, 5528)
|
||
// 既有行为:取冒号前最后一个非数字片段。tqdm 的 desc 是 "daily sync",
|
||
// 因此拿到的是 "sync" 而不是 "daily"。这不是本次重构引入的——
|
||
// 原实现同样是 `parts.last`,此处锁定它是为了避免"顺手修正"改变既有显示。
|
||
XCTAssertEqual(update.tableName, "sync")
|
||
}
|
||
|
||
func testTqdmPercentOnly() throws {
|
||
let update = try XCTUnwrap(SyncOutputParser.parseTqdm("fundamental: 72%|████▌ |"))
|
||
XCTAssertEqual(try XCTUnwrap(update.percent), 0.72, accuracy: 0.0001)
|
||
XCTAssertEqual(update.tableName, "fundamental")
|
||
}
|
||
|
||
func testTqdmFiltersNumericNoiseFromTableName() {
|
||
// 冒号前的纯数字应被过滤,取最后一个非数字片段
|
||
let update = SyncOutputParser.parseTqdm("2026 daily: 10%|█| 1/10")
|
||
XCTAssertEqual(update?.tableName, "daily")
|
||
}
|
||
|
||
func testTqdmIgnoresOverlongName() {
|
||
// 表名过长(>=40 字符)不接受,避免把整行当成表名
|
||
let long = String(repeating: "x", count: 60)
|
||
let update = SyncOutputParser.parseTqdm("\(long): 10%")
|
||
XCTAssertNil(update?.tableName)
|
||
}
|
||
|
||
func testTqdmEmptyReturnsNil() {
|
||
XCTAssertNil(SyncOutputParser.parseTqdm(" "))
|
||
}
|
||
|
||
// MARK: - stderr
|
||
|
||
func testTqdmResidueSkipped() {
|
||
XCTAssertEqual(SyncOutputParser.action(forStderrLine: "|███▌ | 10/20"), .skip)
|
||
XCTAssertEqual(SyncOutputParser.action(forStderrLine: "█▌"), .skip)
|
||
XCTAssertEqual(SyncOutputParser.action(forStderrLine: "▌"), .skip)
|
||
}
|
||
|
||
func testErrorAndWarningPriority() {
|
||
XCTAssertEqual(
|
||
SyncOutputParser.action(forStderrLine: "❌ boom"),
|
||
.log(level: .error, message: "❌ boom")
|
||
)
|
||
// 大小写不敏感
|
||
XCTAssertEqual(
|
||
SyncOutputParser.action(forStderrLine: "Traceback: ERROR occurred"),
|
||
.log(level: .error, message: "Traceback: ERROR occurred")
|
||
)
|
||
XCTAssertEqual(
|
||
SyncOutputParser.action(forStderrLine: "⚠ 轻微异常"),
|
||
.log(level: .warning, message: "⚠ 轻微异常")
|
||
)
|
||
XCTAssertEqual(
|
||
SyncOutputParser.action(forStderrLine: "WARNING: slow"),
|
||
.log(level: .warning, message: "WARNING: slow")
|
||
)
|
||
}
|
||
|
||
func testSuccessFailRateExtraction() {
|
||
XCTAssertEqual(SyncOutputParser.action(forStderrLine: "成功: 42"), .successCount(42))
|
||
XCTAssertEqual(SyncOutputParser.action(forStderrLine: "失败: 3"), .failCount(3))
|
||
XCTAssertEqual(SyncOutputParser.action(forStderrLine: "速率: 2.69"), .rate(2.69))
|
||
}
|
||
|
||
func testDbInfoAndSummary() {
|
||
XCTAssertEqual(
|
||
SyncOutputParser.action(forStderrLine: "数据库连接已建立"),
|
||
.log(level: .info, message: "数据库连接已建立")
|
||
)
|
||
XCTAssertEqual(
|
||
SyncOutputParser.action(forStderrLine: "同步完成"),
|
||
.log(level: .info, message: "同步完成")
|
||
)
|
||
}
|
||
|
||
func testUnmatchedStderrReturnsNil() {
|
||
XCTAssertNil(SyncOutputParser.action(forStderrLine: "完全无关的一行"))
|
||
XCTAssertNil(SyncOutputParser.action(forStderrLine: " "))
|
||
}
|
||
|
||
// MARK: - emoji 与 NSRange(原实现的真实缺陷)
|
||
|
||
/// 含 emoji 的行,行尾正则必须仍能匹配。
|
||
///
|
||
/// `⚠️` 在 UTF-16 下占 2 个单元(U+26A0 + U+FE0F),但 `String.count` 只算 1
|
||
/// 个字素簇。原实现用 `NSRange(location: 0, length: trimmed.count)` 构造范围,
|
||
/// 长度偏小 → 行尾的「成功: N」落在范围之外被静默丢弃。
|
||
/// 修复方式是统一用 `(s as NSString).length`。
|
||
func testEmojiLineStillMatchesTrailingRegex() {
|
||
let line = "⚠️ 处理完毕 成功: 42"
|
||
// 先证明这行确实存在 UTF-16 长度差异,否则本用例没有意义
|
||
XCTAssertGreaterThan((line as NSString).length, line.count, "该行应含 emoji 以拉开长度差")
|
||
|
||
XCTAssertEqual(SyncOutputParser.action(forStderrLine: line), .successCount(42))
|
||
}
|
||
|
||
func testEmojiLineRateExtraction() {
|
||
let line = "✅ 速率: 3.5 stock/s"
|
||
XCTAssertEqual(SyncOutputParser.action(forStderrLine: line), .rate(3.5))
|
||
}
|
||
|
||
// MARK: - 缓冲切分
|
||
|
||
func testSplitOnCarriageReturnKeepsRemainder() {
|
||
var buffer = "a\rb\rc部分"
|
||
let lines = SyncOutputParser.split(&buffer, on: "\r")
|
||
XCTAssertEqual(lines, ["a", "b"])
|
||
XCTAssertEqual(buffer, "c部分", "未以分隔符结尾的残片必须留在缓冲里")
|
||
}
|
||
|
||
func testSplitOnNewline() {
|
||
var buffer = "line1\nline2\n"
|
||
let lines = SyncOutputParser.split(&buffer, on: "\n")
|
||
XCTAssertEqual(lines, ["line1", "line2"])
|
||
XCTAssertEqual(buffer, "")
|
||
}
|
||
|
||
func testSplitNoSeparatorKeepsBuffer() {
|
||
var buffer = "no separator here"
|
||
XCTAssertEqual(SyncOutputParser.split(&buffer, on: "\n"), [])
|
||
XCTAssertEqual(buffer, "no separator here")
|
||
}
|
||
}
|