MooTool next-compose:Vault 外部删除冲突证据链的实现与验收(DIFF-610 深度解析)
MooTool next-compose:Vault 外部删除冲突证据链的实现与验收(DIFF-610 深度解析)
本文以 MooTool Compose Desktop 版(
next-compose)的 DIFF-610 变更记录为骨架,围绕 JSON Vault(F04)在“本地脏编辑 + 磁盘外部删除”场景下如何产出deleted冲突态、并在 Compose UI 测试中生成「另存副本」焦点帧证据 这条完整链路展开。读者将掌握:外部删除冲突从监视器轮询到VaultConflictState.deleted的判定流程、sample.json走查夹具与prepare-vault-conflict-evidence.sh步骤 3b 的配合方式、以及 macOS 上全量 Compose 测试规避 JVM exit 134 的构建配置手段。
一、DIFF-610 在做什么:F04 §A 外部删除冲突证据链
DIFF-610 是 MooTool next-compose 增量开发记录中编号第 610 的变更片(基线为 DIFF-609 工作区)。它解决一个很具体的验收证据缺口:此前(DIFF-605、帧 200)已经覆盖了 sample.json 被外部改写时的冲突「重新加载」焦点帧,但外部删除(文件在磁盘上被 rm)这一分支还没有对应的证据帧。DIFF-610 补齐的是这条链路:
- F04 §A:
VaultConflictDeletedProductEvidenceFlowTest以sample.json为夹具,构造「脏编辑 +rm→deleted冲突 + Presentation 断言」的完整流程测试;同时对齐prepare-vault-conflict-evidence.sh中注释说明的 步骤 3b(外部删除走查步骤)。 - 证据:
VaultConflictDeletedProductEvidenceCaptureTest输出 Compose 焦点帧205-compose-vault-external-conflict-sample-deleted-savecopy-tab-focus.png(680×460),专门呈现外部删除态下「另存副本」按钮的 Tab 焦点环。 - 不重复 609:DIFF-609 已经产出 merge 冲突期间 push 禁用的帧 204,DIFF-610 的帧 205 与之互斥,避免同一帧被反复复用。
整条证据链在验收文档 next-compose/docs/evidence/2026-09-17-vault-conflict-product-window/results.md 中有登记:「§A 证据 sample.json 外部删除→冲突链 | VaultConflictDeletedProductEvidenceFlowTest(DIFF-610)」与「§A 证据 sample 外部删除 saveCopy 帧 | 205-…png(DIFF-610)」两条,并且明确标注「已自动化(不能代替产品主窗截图)」。
需要说明的是:这类 Compose 焦点帧是在非产品主窗的 UI 测试里截取的,用于在无人值守时锁住「Tab 焦点落在哪颗按钮」的呈现细节;产品主窗的最终截图仍须通过
runDistributable+ 人工 GUI 完成(见下文第五节)。
二、外部删除冲突如何被判定:VaultConflictEngine.decide 的 deleted 分支
要理解 DIFF-610 的测试为什么断言 pending.deleted == true、pending.diskText == null,需要先看冲突判定引擎。VaultConflictEngine.kt 定义了五种变更分类:
enum class VaultChangeKind {
Ignored, Reload, Conflict, Deleted, TreeChanged
}
核心判定函数 decide(changedPath, currentFile, editorText, savedText, diskText, expectedOwnHash) 的逻辑:
| 输入组合 | 判定结果 | 含义 |
|---|---|---|
| 变更路径不是当前打开文件 | TreeChanged |
仅刷新树,不打扰编辑器 |
diskText == null 且编辑器内容 == 已保存内容 |
Deleted |
文件没了、本地也干净,直接清会话 |
diskText == null 且编辑器内容 != 已保存内容 |
Conflict(deleted) |
外部删除 + 本地脏编辑 → 冲突 |
diskHash == expectedOwnHash |
Ignored |
自写回写被预期哈希放行 |
diskText == editorText |
Ignored |
内容一致,无冲突 |
editorText == savedText |
Reload |
本地干净,直接重载磁盘版本 |
diskText == savedText |
Ignored |
磁盘还是基准内容,忽略 |
| 其余 | Conflict |
三方内容互不相同 |
判断依据是 UTF-8 内容的 SHA-256 哈希(见 sha256Text),而不是 mtime;文件超过 MAX_HASH_BYTES(8 MiB)时退化为 size: 指纹(DIFF-026 中记载「大于 8 MiB 的文件只比较 size 指纹」)。canOverwrite 则保证外部版本与基准、编辑器都不同时拒绝覆盖保存,这正是「禁止后写覆盖」的工程落地。
DIFF-610 的流程测试命中的正是上表第二行:rm 之后 diskText == null、编辑器文本是脏的(与 savedText 不同),因此得到 VaultChangeKind.Conflict,再以 disk == null 的实参构造出 VaultConflictState(relativePath, editorText, diskText = null, deleted = true)(见 JsonVaultSession.kt)。
三、测试如何复现“脏编辑 + 外部删除”:VaultConflictDeletedProductEvidenceFlowTest
DIFF-610 的流程测试 VaultConflictDeletedProductEvidenceFlowTest.kt 是整条证据链的可验证核心,其步骤可以逐条对照真实代码:
- 构造隔离数据目录:
createTempDirectory("mootool-vault-deleted-evidence-flow-")+AppPaths.resolve(...).ensureCreated(),配合SettingsRepository/AppDatabase/AppContainer组装出与产品一致的运行时容器,全程不触碰本机默认数据目录。 - 写入走查夹具:通过
VaultConflictProductEvidencePresentation.EVIDENCE_JSON_RELATIVE_PATH(即sample.json)与evidenceJsonInitialBody创建文件。夹具正文为:
{
"fixture": "vault-external-conflict",
"step": "open in app then edit without saving"
}
- 制造脏编辑:
JsonSession中把savedText设为初始正文,再用editor.setText(初始正文.replace("open in app then edit without saving", "dirty before external delete"))写入未保存的修改,从而模拟「打开后编辑不保存」。 - 启动监视器:以
intervalMs = 60创建VaultRevisionMonitor,monitor.start()后立即rebaseline()(防止把测试自身的初始写入当作外部变更)。 - 外部删除:
Thread.sleep(90)后执行container.jsonVault.root().resolve(path).deleteIfExists(),等价于产品走查脚本的rm。 - 断言冲突态:
CountDownLatch等待监视器回调,然后依次断言:pending.relativePath == "sample.json"且matchesEvidenceWalkthroughPath(...)通过;pending.deleted == true、pending.diskText == null;VaultConflictPresentation.showReloadAction(deleted)为false(外部删除态不出现「载入磁盘版本」按钮);hintMessageKey(deleted) == "vault.conflict.hintDeleted";saveCopyProminent(deleted)为true(删除态主操作是「另存本地副本」);pending.editorText仍包含"dirty before external delete"(编辑内容在冲突期间被完整保留)。
这些断言与 VaultConflictPresentation.kt 的实现一一对应,也决定了 VaultConflictDialog.kt 中按钮的显隐与权重(删除态隐藏 reload 按钮、saveCopy 升级为主按钮)。
同样为 F01 随手记(sample-external.md)存在对称实现 QuickNoteVaultConflictDeletedProductEvidenceFlowTest.kt,对应 DIFF-611 的帧 206。
四、焦点帧证据:VaultConflictDeletedProductEvidenceCaptureTest 与帧 205
DIFF-610 的第二个测试 VaultConflictDeletedProductEvidenceCaptureTest.kt 负责产出证据 PNG:
- 使用
runDesktopComposeUiTest(width = 680, height = 460)直接渲染VaultConflictDialog(非产品主窗),构造VaultConflictState(relativePath = "sample.json", editorText = 脏正文, diskText = null, deleted = true); - 通过
FocusRequester把键盘焦点显式放到「另存副本」按钮上(Modifier.focusRequester(saveCopyFocus)+runOnIdle { saveCopyFocus.requestFocus() }); onRoot().captureToImage()截帧,写入docs/evidence/2026-09-15-inspector-screencapture/windows/205-compose-vault-external-conflict-sample-deleted-savecopy-tab-focus.png;- 最后用一个朴素像素统计
countRingPixels(image, 0x316DC0)断言画面上至少出现 8 个主色焦点环像素,作为「焦点确实落在保存副本按钮」的自动校验。
该帧与 DIFF-605 的帧 200(sample.json 冲突「重新加载」焦点帧)、DIFF-441 的帧 146(删除态对话框无 reload 按钮的 Tab 帧)互补不重复:200 覆盖「磁盘改写」分支,146/205 覆盖「磁盘删除」分支,但 205 首次把焦点环落到删除态的「另存副本」主按钮上。
五、走查脚本步骤 3b:prepare-vault-conflict-evidence.sh 与 rm 场景
DIFF-610 的测试是对自动化闭环的锁定,而产品主窗人工验收走查由 prepare-vault-conflict-evidence.sh 准备隔离数据。脚本核心动作:
- 通过
MOOTOOL_COMPOSE_DATA_DIR(由 lib/product-evidence-common.sh 的mootool_evidence_resolve_data_dir解析,未设置则mktemp -d)建立隔离数据根; - 在
data/vaults/json/sample.json写入走查夹具(与测试断言用同一份正文); - 在
data/vaults/quick-note/sample-external.md写入随手记夹具; - 末尾自检两个文件存在,并输出步骤 3 / 3b / 3c 的操作提示。
其中 DIFF-610 涉及的 步骤 3b 正是外部删除走查(须先打开 sample.json 并保持脏编辑,再从终端执行 rm):
# 外部改写(步骤 3):
# printf '%s\n' '{"disk":true}' > "${VAULT_JSON}/sample.json"
# 外部删除(步骤 3b,须先打开 sample.json 并保持脏编辑):
# rm "${VAULT_JSON}/sample.json"
# 随手记外部删除(步骤 3c,须先打开 sample-external.md 并保持脏编辑):
# rm "${VAULT_QN}/sample-external.md"
脚本末尾会打印 MOOTOOL_COMPOSE_DATA_DIR 单行 export(不要 eval 整段输出,因为注释行含 shell 语法不可执行),随后即可用 ./gradlew :composeApp:runDistributable --offline 启动产品主窗,重复测试所覆盖的走查路径。verify-product-evidence-prep.sh 可在无 GUI 的 CI 上非交互验证脚本准备结果。
六、配套构建配置:macOS 全量 Compose 测试的稳定性参数
DIFF-610 的「其它」段记录了一个工程性细节:composeApp/build.gradle.kts 中 desktopTest 设 maxParallelForks = 1 / forkEvery = 80 / -Xmx3g,用于减轻 macOS 全量 Compose 测试进程被 JVM 以 exit 134 杀掉的偶发问题。当前仓库 composeApp/build.gradle.kts 中的实际配置为:
/** Compose Desktop UI tests share AWT; serial forks reduce macOS JVM abort (exit 134) on full suite. */
tasks.named<Test>("desktopTest") {
maxParallelForks = 1
jvmArgs("-Xmx2g")
}
可以看到:maxParallelForks = 1 与文档一致(串行 fork,规避 Compose UI 测试共享 AWT 导致的崩溃);堆内存当前为 -Xmx2g,与文档记录的 -Xmx3g 存在差异,说明该参数仍在按实际机器内存调优,引用本文时请以仓库当前值为准。此外,DIFF-610 与 DIFF-611 均强调 macOS 上 Gradle daemon 下偶发 executor exit 134,可加 --no-daemon 规避:
./gradlew :composeApp:desktopTest --offline --no-daemon
(前提:本机安装 JDK 21,且 desktopTest 为离线测试集,含 2 个默认跳过的公网 smoke 用例。)
七、证据链在验收体系中的位置与局限
DIFF-610 只是 MooTool next-compose 数百条 DIFF 切片之一,它的价值在于把「外部删除冲突」这一行为用两层证据固定下来:
| 证据层 | 覆盖内容 | 位置 |
|---|---|---|
| 流程测试(自动) | 脏编辑 + rm → deleted 冲突态、Presentation 断言 |
VaultConflictDeletedProductEvidenceFlowTest |
| 帧测试(自动) | 删除态「另存副本」Tab 焦点环 | 205-compose-vault-external-conflict-sample-deleted-savecopy-tab-focus.png |
| 走查脚本(半自动) | 隔离目录夹具 + 步骤 3/3b/3c 操作提示 | prepare-vault-conflict-evidence.sh |
| 产品主窗(待人工) | 真实 GUI 下冲突叠层的最终呈现 | docs/evidence/2026-09-17-vault-conflict-product-window/results.md 登记 |
注意验收记录中的边界声明:这类 Compose 帧 不能 代替产品主窗截图;acceptance.md 仍将 F04/F01 冲突 UI 标记为「待验收」。因此 DIFF-610 的定位是「自动化闭环 + 走查准备」,而不是宣称冲突 UI 已整体验收。
八、快速上手:如何验证 DIFF-610 的链路
若要在本地复现 DIFF-610 覆盖的行为,推荐顺序如下:
- 跑定向流程测试(验证判定与断言):
cd next-compose
./gradlew :composeApp:desktopTest \
--tests com.rememberber.mootool.next.compose.domain.VaultConflictDeletedProductEvidenceFlowTest \
--offline --no-daemon
- 跑帧证据测试(重新生成帧 205 并自动断言焦点环像素):
./gradlew :composeApp:desktopTest \
--tests com.rememberber.mootool.next.compose.features.vault.VaultConflictDeletedProductEvidenceCaptureTest \
--offline --no-daemon
- 准备产品走查目录(无 GUI 也可先验证脚本):
export MOOTOOL_COMPOSE_DATA_DIR="$(mktemp -d /tmp/mootool-compose-evidence-XXXX)"
./scripts/prepare-vault-conflict-evidence.sh
./scripts/verify-product-evidence-prep.sh
./gradlew :composeApp:runDistributable --offline
# 打开 JSON → sample.json → 编辑不保存 → 终端 rm 该文件 → ≤1s 内应弹出「另存本地副本 / 继续编辑」叠层
整个 DIFF-610 的核心结论可以概括为一句话:当用户在 Vault 中打开文件并留有未保存编辑、而文件在磁盘上被外部删除时,MooTool Compose Desktop 版不再允许保存悄悄覆盖,而是通过内容哈希监视器把场景归类为 deleted 冲突,隐藏无效的「载入磁盘版本」按钮,并把「另存本地副本」提升为默认主操作——DIFF-610 用流程测试与帧 205 把这一行为固定为可回归的证据。
