Files
FinDataHub/Tests/FinDataHubTests/SyncOutputParserTests.swift
FinDataHub fa23425cbe refactor(client): 抽取 SyncOutputParser,解析逻辑首次可测
## 为什么选这个切入方式

原计划先引入 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。
2026-10-27 00:05:18 +08:00

214 lines
9.0 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}