14.1 属性测试与不变量

属性测试不问「跑一遍对不对」,而问「一条命题是否对所有输入都成立」。本节把第 3 / 6 / 8 章的不变量写成六条属性:好感度恒在 [0,100]、set 类算子幂等、add 故意不幂等、静态死路 0、5 个结局全可达、每状态至少一个可用选项,并给每条属性配一个必须 fail 的注入式负例。

14.1 属性测试与不变量

站内服务端的叙事分支状态架构 解决的是另一类正确性:同一份分支状态在并发写入与断线重连下不能乱,靠的是服务端的锁、版本号与幂等重放。本节不碰网络、不碰数据库、也不碰并发,只问单机引擎内核自己的可测试性:怎么把「这份规格在内核里永远成立的性质」写成机器能反复验证的属性(property),而不是靠人跑一遍、看着对。第 3 章证明了效果应用是纯函数、第 6 章证明了静态与动态死路为 0、第 8 章证明了好感度恒被钳在区间内——本节把这些一次性的结论升级成每次改动都要重跑的属性,并给每条属性配一个必须 fail 的负例。

示例测试与属性测试的分工

示例测试(example-based test)挑一个具体输入,断言一个具体输出;属性测试(property-based test)不挑输入,它断言一条命题对所有输入都成立。叙事引擎特别适合后者,因为它的状态域是有限的整数格:好感度 0–100、18 个布尔 flag、9 个布尔物品——「所有输入」不是无穷集,而是可采样、可枚举的离散集合。

更关键的是:引擎的核心函数是纯函数(applyEffects 返回新对象,不改入参),所以一条属性只要在采样到的路径上成立,就有一个很强的经验支撑;而一条属性如果在任何一条路径上被违反,就能被采样抓到。属性测试因此是「用少量随机输入覆盖大状态空间」的性价比最高的手段。

六条属性

六条属性全部来自前三章已经证明过的不变量,本节把它们重新表述成可重复执行的断言:

#属性机件真实规格实测
①好感度恒 ∈ [0,100] 且为整数沿采样路径逐步核对52840 个状态,越界 0
②set / grant / revoke 幂等apply(apply(s,e),e) == apply(s,e)49 条,违例 0
③add 故意不幂等重复应用后规范串改变66 条,改变 66
④静态死路 0 个无出边且非结局的节点0
⑤5 个结局全部可达沿采样路径收集走到的结局5 / 5
⑥每可达状态至少 1 个可用选项逐状态数可用选项最小 1,零选项状态 0

②与③是一对必须分开的属性:set 是赋值、grant 是置位、revoke 是清零,三者都与当前值无关,所以重复应用结果不变;而 add 是累加,重复应用必须改变状态。把这两条混起来,就会误以为「add 非幂等」是要修的缺陷,进而把累加改成赋值、毁掉整条好感度曲线(3.2 节已把这条口径写死)。

属性从哪里来:不变量,不是灵感

属性测试的第一难点不是写测试,是找属性。本节不发明属性,只做一件事:把前几章已经证明过的不变量抄过来,改写成「每次都要重跑」的形式。来源有三处:

来源原来的结论改写成属性
第 3 章 效果的应用函数性 115/115、set 类幂等 49/0、add 非幂等 66/66属性 ②③
第 6 章 死路检测静态死路 0、动态死路状态 0、最少可用选项 1属性 ④⑥
第 8 章 好感度不变量钳位越界 0、初值全在区间、门限 15 条全可达属性 ①⑤

这样做的收益是零重复设计:不变量已经在各章用可执行的方式定义过,属性测试只是把它们搬进一个统一的、每次改动都跑的套件。代价也要写清:属性测试不会发现新缺陷——它只保证「已经知道的结论不会被改坏」。发现新缺陷靠穷举(第 5 章)与模糊测试(下一节)。

六条属性的覆盖边界

属性测试证明的是「采样到的状态上没有违反」,不是「所有状态上没有违反」。这条边界必须写清,否则会把经验性结论当成定理:

属性覆盖口径没覆盖什么
① 好感度范围2000 条采样路径上的全部状态采样没走到的状态
②③ 幂等全部 115 条选项,静态无(是穷举)
④ 静态死路全部 48 个节点,静态无(是穷举)
⑤ 结局可达2000 条采样路径采样没走到的结局
⑥ 可用选项2000 条采样路径上的状态采样没走到的状态

②③④是静态穷举(对整张选项表 / 节点表逐条查),所以是完备的;①⑤⑥是沿采样路径,所以是经验性的。把这两类分开标注,读者才知道哪条结论硬、哪条结论依赖采样规模。要补硬①⑥,就把采样换成第 6 章的投影状态完整 BFS——代价从毫秒涨到秒级。

每条属性配一个必须 fail 的负例

「检查器返回 0」有两种可能:真的没有缺陷,或者检查器坏了、永远返回 0。只有负例能区分这两者。 六条属性各配一个注入式负例:

#注入期望(必须成立)
①造一个 yan = 150 的状态越界计数 = 1
②给一个 set 类选项注入 add.yan = 5原始幂等判定报出该选项
③把某选项的 add 改成 0它不再改变,改变计数降到 65
④抽掉 d_pier_2 的全部出边静态死路报出 d_pier_2
⑤把通往灯守结局的唯一选项改成恒假可达结局降到 4,缺灯守
⑥把 d_dq_1 全部选项改成恒假最小可用选项落到 0

六组负例全部是内存内构造:克隆规格、改一处、喂给检查器,config/story_spec.json 一个字节都不动。

P 层:六条属性 + 六个负例

P 层把六条属性与六个负例写成 node --test。机件在 core/src/inkwell/ne_14_1_property.ts,测试直接复用公共内核 engine.ts 的 applyEffects / canonical / samplePaths,不改动内核:

// file: core/test/ne_14_1_property.test.ts
// tier: P
// verify: node --test 'core/test/ne_14_1_property.test.ts'
// 14.1 属性测试与不变量:六条属性 + 六个必须 fail 的注入式负例。
// 正例断言真实规格全过;负例断言检查器真的会开火(不是永远返回 0 的摆设)。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

import type { Spec } from "../src/inkwell/engine.ts";
import {
  checkProperties,
  rangeViolations,
  idempotencyViolations,
  nonIdempotentChoices,
  addChangedCount,
  staticDeadEnds,
  reachableEndings,
  outOfRangeAffinity,
  injectAdd,
  zeroAdd,
  dropChoices,
  muteNode,
  muteChoice,
} from "../src/inkwell/ne_14_1_property.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const spec = JSON.parse(readFileSync(join(HERE, "../../config/story_spec.json"), "utf8")) as Spec;
const SEED = 20261016;
const N = 2000;

test("属性① 好感度恒 ∈ [0,100] 整数:采样 2000 条路径越界 0", () => {
  const r = checkProperties(spec, N, SEED);
  assert.ok(r.states > 0);
  assert.equal(r.affinityViolations, 0);
  console.log(`① 好感度 ∈ [0,100]:扫描 ${r.states} 个状态,越界 ${r.affinityViolations}`);
});

test("负例① 注入越界状态 yan=150:rangeViolations 必须报出 1", () => {
  const aff = outOfRangeAffinity(spec, "yan", 150);
  assert.equal(rangeViolations(aff, spec.state.vars), 1);
});

test("属性② set/grant/revoke 幂等:49 条违例 0", () => {
  const r = checkProperties(spec, N, SEED);
  assert.equal(r.setIdempotentTotal, 49);
  assert.equal(r.setIdempotentViolations, 0);
});

test("负例② 给 c_pier_1a 注入 add.yan=5:原始幂等判定必须报出它,而真实规格报 66 条", () => {
  const bad = injectAdd(spec, "c_pier_1a", "yan", 5);
  assert.ok(nonIdempotentChoices(bad).includes("c_pier_1a"));
  const real = nonIdempotentChoices(spec);
  assert.equal(real.length, 66, "真实规格非幂等的恰是 66 条 add 类选项");
  assert.ok(!real.includes("c_pier_1a"), "真实规格下 c_pier_1a 是幂等的 set 类选项");
});

test("属性③ add 故意不幂等:66 条含 add 的选项重复应用全部改变", () => {
  const r = checkProperties(spec, N, SEED);
  assert.equal(r.addTotal, 66);
  assert.equal(r.addChanged, 66);
});

test("负例③ 把某选项的 add 改成 0:它不再改变,属性③必须 fail", () => {
  const r = addChangedCount(zeroAdd(spec, "c_pier_1b"));
  assert.equal(r.total, 66);
  assert.equal(r.changed, 65, "注入 add=0 后应恰有一条不再改变");
});

test("属性④ 静态死路 0 个", () => {
  const r = checkProperties(spec, N, SEED);
  assert.deepEqual(r.staticDeadEnds, []);
});

test("负例④ 抽掉 d_pier_2 的全部出边:静态死路检测必须报出 d_pier_2", () => {
  assert.deepEqual(staticDeadEnds(dropChoices(spec, "d_pier_2")), ["d_pier_2"]);
});

test("属性⑤ 5 个结局全部可达", () => {
  const r = checkProperties(spec, N, SEED);
  assert.equal(r.reachableEndings.length, 5);
  assert.deepEqual(r.missingEndings, []);
});

test("负例⑤ 把通往 e_lit 的唯一选项 c_dq_2a 改成恒假:灯守结局不可达", () => {
  const reached = reachableEndings(muteChoice(spec, "c_dq_2a"), N, SEED);
  assert.equal(reached.length, 4);
  assert.ok(!reached.includes("灯守"));
});

test("属性⑥ 每可达状态至少 1 个可用选项:最小可用选项数 >= 1", () => {
  const r = checkProperties(spec, N, SEED);
  assert.ok(r.minAvailableOptions >= 1);
  assert.equal(r.zeroOptionStates, 0);
  console.log(`⑥ 每状态可用选项最小 ${r.minAvailableOptions},零选项状态 ${r.zeroOptionStates}`);
});

test("负例⑥ 把 d_dq_1 全部选项改成恒假:最小可用选项落到 0", () => {
  const r = checkProperties(muteNode(spec, "d_dq_1"), N, SEED);
  assert.equal(r.minAvailableOptions, 0);
  assert.ok(r.zeroOptionStates > 0);
});

本机实跑 node --test 'core/test/ne_14_1_property.test.ts':

① 好感度 ∈ [0,100]:扫描 52840 个状态,越界 0
⑥ 每状态可用选项最小 1,零选项状态 0
✔ 属性① 好感度恒 ∈ [0,100] 整数:采样 2000 条路径越界 0 (64.342667ms)
✔ 负例① 注入越界状态 yan=150:rangeViolations 必须报出 1 (0.121042ms)
✔ 属性② set/grant/revoke 幂等:49 条违例 0 (54.818417ms)
✔ 负例② 给 c_pier_1a 注入 add.yan=5:原始幂等判定必须报出它,而真实规格报 66 条 (1.269458ms)
✔ 属性③ add 故意不幂等:66 条含 add 的选项重复应用全部改变 (62.024417ms)
✔ 负例③ 把某选项的 add 改成 0:它不再改变,属性③必须 fail (0.656167ms)
✔ 属性④ 静态死路 0 个 (59.056167ms)
✔ 负例④ 抽掉 d_pier_2 的全部出边:静态死路检测必须报出 d_pier_2 (0.4325ms)
✔ 属性⑤ 5 个结局全部可达 (54.661417ms)
✔ 负例⑤ 把通往 e_lit 的唯一选项 c_dq_2a 改成恒假:灯守结局不可达 (50.675667ms)
✔ 属性⑥ 每可达状态至少 1 个可用选项:最小可用选项数 >= 1 (60.879ms)
✔ 负例⑥ 把 d_dq_1 全部选项改成恒假:最小可用选项落到 0 (51.182375ms)
ℹ tests 12
ℹ suites 0
ℹ pass 12
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 598.356875

12 条全过:6 条属性在真实规格上成立,6 条负例各自被抓到。

负例为什么是核心验收

一份属性测试套件最容易失败的方式,不是漏掉某条属性,而是属性写得太弱——弱到永远返回「通过」。负例正是用来证伪这个怀疑的:它构造一个已知违反属性的输入,断言检查器必须报错。如果负例也「通过」了(即检查器没报错),套件本身就有缺陷。

三处细节值得记下:

  • 负例要打在不同的失败路径上。 ⑥ 把 d_dq_1 改成恒假,是让「走到最后一个枢纽前就无路可走」被抓到;④ 抽掉出边,是让静态判据独立开火。两者都返回「有问题」,但走的是不同代码路径。
  • 负例要能区分「分类」与「违规」。 ② 的负例不是断言「幂等检查器报出这条」,而是断言「不按 add 分类的原始判定报出这条」。因为给一个 set 类选项注入 add,它就从「幂等集合」移到了「累加集合」——报的是分类边界,不是同一条属性的反例。真实规格下原始判定恰好报 66 条(全是 add 类),注入后多出 c_pier_1a。
  • 负例只改内存、不改真源。 注入全部在克隆体上做,跑完即丢。真源 config/story_spec.json 始终是唯一基准。

自检表

检查项合格标准
属性可执行六条属性都是整数计数或布尔判定,无主观判断
口径分开幂等(②)与累加(③)分列,不混用
负例必给每条属性一个注入负例,且负例必须 fail
真源不动负例在克隆体上做,story_spec.json 零改动
数字实跑正文每个数字都能在 node --test 输出里找到

属性测试回答「内核的性质是否还成立」。但属性测试只覆盖采样到的路径——下一节把采样本身做大:用自实现的可播种 PRNG 跑两万条随机路径,把随机路径当成模糊测试的输入。

阅读导航:上一节:13.3 偏差与容差 · 下一节:14.2 随机路径的模糊测试 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长