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
- 将
sample.log中 Types 的 35 改成 20,手动重跑两个报表,总计应为 80,主题分别为 60 与 20。 - 重跑脚本仍应通过,因为脚本创建自己的固定验收输入。这避免日常样本改动误伤验收。
- 恢复
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,而不必立即加入数据库和并发。