Course Builder个人学习工作台 · 本地模式
OCaml:从类型推导到可测试的命令行工具

18 · 最终实战:构建、验收与独立扩展

目标:从现有源码构建 StudyLog,检查完整命令行行为,并独立增加平均时长统计。

今天没有新的架构。命令行把路径交给 Store,Store 读取文件并调用 Core,Core 返回合法记录,Report 按主题汇总。计算、I/O 和展示各自负责明确的一段工作。如果出了问题,先判断是哪一层的契约不成立,再找对应代码。

最终目录应包含以下文件。缺少时回到标注课次恢复完整内容,不需要猜实现。

文件来源与职责
dune-project、lib/dune、bin/dune第 12 课:构建与依赖
lib/core.ml第 12 课完整核心,加第 13 课两个访问函数
lib/core.mli第 13 课:抽象记录与公开接口
test/core_test.ml第 14 课:解析与统计测试
lib/store.ml、lib/store.mli、test/store_test.ml第 15 课:文件边界与测试
lib/report.ml、lib/report.mli、test/report_test.ml第 17 课:分组与测试
bin/main.ml、sample.log第 17 课的最终版本
test/dune第 15 课双测试配置,加第 17 课第三个 stanza

实验:把用户能观察到的行为检查一遍

在 studylog/ 执行:

dune build
dune runtest --force
_build/default/bin/main.exe sample.log
_build/default/bin/main.exe --by-topic sample.log

先预测两个报表的总分钟数是否一致。测试应通过,随后两个程序调用的预期输出是:

sessions=3 minutes=95
OCaml: 60
Types: 35

Dune 构建得到的程序可以直接执行,不必每次通过 dune exec。这个产物针对当前系统和工具链;不能据此假定把它复制到任意操作系统都能运行。

现在创建 check-cli.sh,内容如下。它只在项目中的专用 cli-fixtures/ 目录写入验收样本与输出;再次执行会覆盖这些验收文件。

#!/usr/bin/env sh
set -eu
mkdir -p cli-fixtures
printf 'OCaml|25\nTypes|35\nOCaml|35\n' > cli-fixtures/valid.log
printf 'OCaml|25\n\nTypes|bad\n' > cli-fixtures/invalid.log
printf '' > cli-fixtures/empty.log
app=./_build/default/bin/main.exe

test "$("$app" cli-fixtures/valid.log)" = 'sessions=3 minutes=95'
test "$("$app" --by-topic cli-fixtures/valid.log)" = 'OCaml: 60
Types: 35'
test "$("$app" cli-fixtures/empty.log)" = 'sessions=0 minutes=0'
test "$("$app" --by-topic cli-fixtures/empty.log)" = ''
test "$("$app" --help)" = 'Usage: studylog [--by-topic] FILE'

if "$app" cli-fixtures/invalid.log > cli-fixtures/out.txt 2> cli-fixtures/err.txt; then
  exit 1
else
  status=$?
  test "$status" -eq 1
fi
test ! -s cli-fixtures/out.txt
test "$(cat cli-fixtures/err.txt)" = 'line 3: minutes must be an integer'

if "$app" > cli-fixtures/out.txt 2> cli-fixtures/err.txt; then
  exit 1
else
  status=$?
  test "$status" -eq 2
fi
test ! -s cli-fixtures/out.txt
test "$(cat cli-fixtures/err.txt)" = 'Usage: studylog [--by-topic] FILE'
printf 'cli checks passed\n'

test 检查条件,失败会返回非零;set -e 使普通检查失败时停止,set -u 拒绝使用未定义变量。预期失败的程序放在 if 中,显式检查状态,避免脚本过早停止。> 与 2> 分别收集标准输出和标准错误,test ! -s 检查文件没有内容。命令替换 $(...) 会去掉末尾换行,所以这里比较的是报表文本。

sh check-cli.sh

预期输出:

cli checks passed
  1. 将 sample.log 中 Types 的 35 改成 20,手动重跑两个报表,总计应为 80,主题分别为 60 与 20。
  2. 重跑脚本仍应通过,因为脚本创建自己的固定验收输入。这避免日常样本改动误伤验收。
  3. 恢复 sample.log。再说明:为什么核心测试通过,不代表命令行退出状态一定正确?

独立扩展:空输入的平均值是什么

增加 Core.average : session list -> float option。非空时返回平均分钟数,空时返回 None,因为没有样本的平均值未定义。只修改核心实现、接口与核心测试,不改变现有命令行输出。已知两条 25、35 分钟记录平均为 30.0,空列表为 None。

完整参考修改与验收

在 lib/core.ml 末尾追加:

let average sessions =
  match sessions with
  | [] -> None
  | _ -> Some (float_of_int (total sessions) /. float_of_int (List.length sessions))

在 lib/core.mli 末尾追加:

val average : session list -> float option

在 test/core_test.ml 末尾追加:

let () =
  assert (Core.average [] = None);
  match Core.average (expect_ok "OCaml|25\nTypes|35") with
  | None -> failwith "expected an average"
  | Some value ->
      assert (abs_float (value -. 30.0) < 0.000001);
      print_endline "average tests passed"

-. 是浮点减法,abs_float 取绝对值;用很小的允许误差检查计算结果,避免把一般浮点计算都当作精确相等。这里 30.0 可以精确表示,但这种检查方式也适合将来加入非整数平均值。

执行:

dune runtest --force
dune build
sh check-cli.sh

所有测试应通过,原有命令行契约保持。这次扩展可以作为你自己的最终版本保留。

核心测试直接调用函数,不检查 Sys.argv 分支或 exit。完整程序验收因此仍然必要。

如果脚本在某个 test 静默失败,可执行 sh -x check-cli.sh 查看最后运行到的检查,再单独运行对应应用命令。若程序文件不存在,先运行 dune build;若新增函数不可见,检查 .mli 是否同步更新。

完成后,你应能不看答案解释:为什么解析函数不打印;为什么空白行仍计入行号;为什么抽象记录不能在库外直接构造;为什么 Map 的输出顺序不取决于输入顺序;为什么失败需要非零退出状态。下一步可以挑一个小方向继续,例如严格十进制校验或标签参数 API,而不必立即加入数据库和并发。

参考资料