clean test 退出 0 很有用,但它回答的是“已经写下的断言是否通过”,不是“任务系统所有规则都已验证”。一个测试只调用 submitValidated,却不检查返回的 Rejected、旧记录是否被改写,照样可以是绿色。另一方面,少写一个密封状态分支的代码连测试进程都无法启动。让后一种反例进入正常 src/test 会把整个构建变红,正常业务回归也无法完成。

沿用 21 的双模块工程::domain 存放 TaskState 与 TaskStore,根模块运行 Kotlin/Java 的 JUnit 测试。冻结 Kotlin 插件/stdlib 2.2.21、Gradle 8.14、JDK 21,没有新增测试库。Kotlin 的 JUnit JVM 测试指南、kotlin.test API 和 Gradle 测试指南用于确认测试入口;下述诊断和结果均是固定工程本次运行的原始输出。

业务规则要具体到哪一组输入和结果

12 的状态模型只有 `Queued → Running`、从 `Queued` 直接取消、`Running` 后成功/失败/取消等允许的路径;已终止状态不会因为晚到的结果而自动改写。`TaskContractTest` 用一个有限的输入表把这些判断落实成**五个允许、四个拒绝**的用例,而非仅测试 `advanceTask` “被调用过”:
起点及信号 期望 为何值得检查
Queued + Start / Cancel Running / Cancelled 开始和排队期取消是两条不同路径
Running + Success / Failure / Cancel 带结果的终态 / Cancelled 不要把运行期的故障和取消混成同一种状态
Queued + Success 抛 IllegalStateException 未运行就成功不合法
Succeeded + Cancel、Failed + Start、Cancelled + Success 均抛 IllegalStateException 终态不能被随后到达的信号覆盖

正例断言返回的具体状态值,拒绝路径由 assertFailsWith<IllegalStateException> 检查;表中每个条目带起点和信号作为失败提示。旧 TaskStateTest 原本已覆盖部分单点路径,新表并非凭空发明新状态规则,而是让验收矩阵可以逐行对照。这里没有多线程交错控制:这些断言只说明顺序调用的结果,不能拿来证明取消与完成竞争的赢家,也不能把 TaskStore.putIfAbsent 的同步默认实现称作原子操作。

输入被拒绝与存储失败也不是同一种结果。另一个测试先保存 task-22 / alpha,再次提交相同文本得到 Reused,同 ID 不同文本得到 Result.success(SubmissionDecision.Rejected(...)),并检查原值仍是 alpha。注入的合成存储故障则检查 Result.exceptionOrNull() 是同一个 IllegalStateException 对象。若只断言 Result 非空,上述四种结果都能通过,这个绿色测试几乎不给读者任何约束;若只看 getOrThrow() 是否会抛异常,也会漏掉返回值中的 Rejected。这与 13 的失败语义一致,既不声称真正数据库事务已回滚,也没有模拟真实线程竞争。

为什么编译不通过的样本不放进测试源码集

领域的 TaskState 是密封接口。测试工程里另写一个函数,将 Queued、Running、Succeeded、Failed 都列入 when 表达式,却故意漏掉 Cancelled。这不是等到调用时才发生的失败;默认源码集若包含此函数,整个 compileKotlin 就无法通过,业务测试无从运行。

因此负例文件放在 verification/22-incomplete-state.kt,22.sh 在临时独立单模块工程中复制该文件与所需的领域源码,执行离线 compileKotlin,期望退出码 1。本次 K2 诊断指出 'when' expression must be exhaustive. Add the 'Cancelled' branch or an 'else' branch.;完整命令、JDK/Gradle 环境、原始失败输出和断言存于 writing-plans/kotlin/verification/22/。主工程的 clean test 则期望退出 0,两条命令通过不同出口验收,既不将编译错误放进默认构建,也不把“Gradle 有错误”当成目标诊断已经匹配。

when 加一个 else 在这里能让表达式穷尽,却会让将来新状态出现时失去逐项审查的编译提示;是否使用兜底分支应由状态业务含义决定,而不是为了把红色构建变绿。反过来,Kotlin 的编译器拒绝只能阻止它已能静态识别的非法形式,不能替断言检查记录有没有被破坏。这是编译期反例与运行期行为测试各自的边界。

拆模块后,旧负例是否还在验证原来的错误

21 引入 project(":domain") 后,旧版隔离脚本直接复制根 build.gradle.kts、settings.gradle.kts 到 scratch,却没有搬 :domain 工程。若此时直接把任意非零退出叫做“类型拒绝通过”,可能测试到的其实是缺项目依赖。22 将旧 01/02/03/05/07/12/15/16/17/18 脚本复制配置的步骤改为独立单模块:只在 scratch 的配置中去掉 include(":domain") 与 implementation(project(":domain")),把隔离诊断确实需要的领域源码复制进去;真正的主工程仍保持双模块构建。

只对其中旧 16 脚本完成了新布局下的实际复验:从完整工程复制到 /tmp,运行原脚本,退出 0;它的独立编译输出仍只有“擦除后类型不可检查”及“String 不能当 TaskId”两个目标诊断,原始日志见 verification/22/regression-16-*.txt。其余九个脚本已改路径/配置,但没有逐个重新执行,不能借 16 的结果宣布所有历史负例新布局下均通过。旧文章各自封存的源码归档没有被替换。

本篇 bash examples/kotlin-language/verification/22.sh 的主工程回归运行 00–22 共二十三组 JUnit,每组 2/2,包含 Java 消费者;合法 CLI submit alpha 返回 0,缺参数的 submit 返回 1。:domain:test 仍为 NO-SOURCE:二十三组测试运行在根模块,不把空的领域测试任务说成已经覆盖领域所有行为。这个数说明回归的数量和通过状态,不等于分支覆盖率、竞态可复现性或者外部 IO 的正确性。

同名素材提供 含累计双模块工程、Wrapper、RUN/VERSIONS 与必要原始记录的快照 和 SHA-256 校验。解包离线运行需 JDK 21 与本机已缓存依赖。正反测试与页面的完整验收输出可在 writing-plans/kotlin/verification/22/ 重查,CLI 暂不执行真实后台任务。

练习:把 assertEquals(SubmissionDecision.Rejected(...), result.getOrThrow()) 改成只检查 result.isSuccess,能检测同 ID 不同文本被误判为 Reused 吗?不能,两者都可能是成功容器中的值,必须核对具体 decision 与原有存储值。再把漏 Cancelled 的反例放回默认 src/test,是否更能体现测试严谨?它会使常规编译直接失败,回归中的其他 23 组测试甚至运行不了;应该在隔离编译中检查非零退出及目标诊断。

[PATTERN] 对一条语言或业务规则,同时列出正常输入及具体结果、违反规则的输入及具体失败、这一对证据仍未覆盖的路径。把编译拒绝放在独立工程里,用定向诊断排除“恰好因别的原因失败”;把业务测试放在正常构建里,用值与副作用断言排除“绿色但没测到规则”。下一篇再让任务从内存走向文件资源,验收成功、异常和提前返回时谁负责关闭。