两条都来自用户的观察,两条我都犯了自己一直在防的错误。 ## 勾掉 ≠ 做完 先前把 Claude 勾了 completed 的 todo 直接记成 .confirmed(已验收), 理由是"勾状态是它边干边写进文件的,比嘴上说可信"。这个理由站不住 —— 它勾自己的框和它嘴上说"做完了"是同一性质的东西:都是声明,都没有外部证据。 拿它自己勾的框当证据,正是这整个功能存在的理由所要防的事。 改成落到 .claimed(待复核),证据记成 .claimed(...)(isProof = false), 由旁路复核拿真实 git diff 判 confirmed / disputed。 用户的原话:「他勾掉的到底有没有做」—— 只有 diff 能回答。 顺带补上 baselineCommit:缺了它复核只能拿未提交的改动去比,结论全是噪音。 ## 一条 todo 里常常不止一件事 「批次2:设备自动连接(Android 地基修复→状态机→UI→iOS)」是四件。 整条勾掉时里面做了三件还是四件,从条目本身看不出来 —— 而漏掉的往往正是最后那件。 拆分是机械的(按 →、;+/ 等明确分隔符),不调模型:这一步要对每条 todo 都跑,而它要解决的问题很窄。切不出来的("深色模式双端"其实是 iOS+Android) 就不切 —— 切错比不切更糟,用户会对着一堆碎片然后不再相信这个标注。 界面上:行内直接显示「2/4 项」,展开逐项勾。藏在展开区里等于没标, 用户不会为了看有没有分项去逐条展开。 全部分项勾完**不自动**把整条标成已验收 —— 那等于绕过"拿 diff 核对"这一步。 修 bug:标题前缀门槛原来是 12 字,把「把整个权限体系重构一遍:改A、改B」 的前半句当标题剥掉了。中文里 11 个字是实打实的内容,压到 6 字。 411 个测试。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
92 lines
3.6 KiB
Swift
92 lines
3.6 KiB
Swift
import XCTest
|
||
@testable import HubCore
|
||
|
||
/// 把一条塞了好几件事的要点拆开。
|
||
///
|
||
/// 用户的观察:Claude 的一条 todo 里常常不止一件事,整条勾掉时里面
|
||
/// 做了三件还是四件从条目本身看不出来 —— **而漏掉的往往正是最后那件**。
|
||
final class SubPointSplitTests: XCTestCase {
|
||
|
||
private func split(_ text: String) -> [String] {
|
||
AcceptanceItem.split(text).map(\.text)
|
||
}
|
||
|
||
/// 本机真实数据里的样子。
|
||
func testSplitsARealClaudeTodo() {
|
||
XCTAssertEqual(
|
||
split("批次2:设备自动连接(Android 地基修复→状态机→UI→iOS)"),
|
||
["设备自动连接", "Android 地基修复", "状态机", "UI", "iOS"]
|
||
)
|
||
}
|
||
|
||
func testSplitsOnCommonDelimiters() {
|
||
XCTAssertEqual(split("写数据层、接 UI、补测试"), ["写数据层", "接 UI", "补测试"])
|
||
XCTAssertEqual(split("Realtime 冒烟 + /voice/session"), ["Realtime 冒烟", "voice", "session"])
|
||
XCTAssertEqual(split("改 A;改 B"), ["改 A", "改 B"])
|
||
}
|
||
|
||
/// **只有一件事就别拆。**
|
||
///
|
||
/// 返回空数组表示"这条本来就是单一的"—— 不是没拆出来,是不该拆。
|
||
/// UI 靠这个区分要不要显示「n 项」徽章。
|
||
func testSingleThingIsNotSplit() {
|
||
XCTAssertTrue(AcceptanceItem.split("修复登录按钮点不动").isEmpty)
|
||
XCTAssertTrue(AcceptanceItem.split("发布新版本").isEmpty)
|
||
}
|
||
|
||
/// **切不出来就不切。** 「深色模式双端」其实是 iOS + Android 两件,
|
||
/// 但没有分隔符 —— 硬猜会切错,而切错比不切更糟:
|
||
/// 用户会对着一堆莫名其妙的碎片,然后不再相信这个标注。
|
||
func testDoesNotGuessWithoutDelimiters() {
|
||
XCTAssertTrue(AcceptanceItem.split("批次4:深色模式双端").isEmpty)
|
||
}
|
||
|
||
/// 冒号前的短前缀是标题,不算一件事。
|
||
func testDropsShortTitlePrefix() {
|
||
XCTAssertEqual(split("阶段一:改 A、改 B"), ["改 A", "改 B"])
|
||
}
|
||
|
||
/// 长前缀本身就是内容,不能当标题剥掉。
|
||
func testKeepsLongPrefixesAsContent() {
|
||
let parts = split("把整个权限体系重构一遍:改 A、改 B")
|
||
XCTAssertTrue(
|
||
parts.contains { $0.contains("权限体系") },
|
||
"标题前缀是「批次2」那种量级;再长就是内容,剥掉就丢信息了:\(parts)"
|
||
)
|
||
}
|
||
|
||
/// 切碎的噪音要滤掉。
|
||
func testFiltersOutFragments() {
|
||
XCTAssertFalse(split("改 A、、、改 B").contains(""))
|
||
}
|
||
|
||
/// 构造要点时自动拆分,不用调用方记得做。
|
||
func testItemSplitsOnConstruction() {
|
||
let item = AcceptanceItem(text: "写数据层、接 UI", origin: .assistantTask)
|
||
|
||
XCTAssertEqual(item.parts.map(\.text), ["写数据层", "接 UI"])
|
||
XCTAssertEqual(item.partsTotal, 2)
|
||
XCTAssertEqual(item.partsDone, 0)
|
||
}
|
||
|
||
/// 显式传了 parts 就用传进来的(从盘上读回来的场景)。
|
||
func testExplicitPartsWin() {
|
||
let item = AcceptanceItem(
|
||
text: "写数据层、接 UI", origin: .assistantTask,
|
||
parts: [SubPoint(text: "写数据层", done: true), SubPoint(text: "接 UI")]
|
||
)
|
||
|
||
XCTAssertEqual(item.partsDone, 1)
|
||
}
|
||
|
||
/// 没有分项的要点,进度按整条算。
|
||
func testUnsplitItemCountsAsOne() {
|
||
var item = AcceptanceItem(text: "发布新版本", origin: .userPrompt)
|
||
XCTAssertEqual(item.partsTotal, 1)
|
||
XCTAssertEqual(item.partsDone, 0)
|
||
|
||
item.status = .confirmed
|
||
XCTAssertEqual(item.partsDone, 1)
|
||
}
|
||
}
|