test(delegation): 把標記與判準正本雙向綁住

沒有這條斷言,兩邊會慢慢漂開,而漂開時不會有任何東西報錯:正本上多標一步不會壞,
判準表少列一項也不會壞,只是下一個讀的人會以為自己讀到的是全部。

十三條,重點在三處:正本上被標記的集合等於判準表列出的集合;會問使用者的步驟一律
未被標記(判準第二條的迴歸保護,四個點名的步驟加上一次全域掃描);被標記的步驟碰得到
寫入時要寫明哪一半不委派(第四條)。

掃描類的斷言都補上自我檢查,因為這種測試最常見的壞法是「一條都沒掃到」而它照樣是綠的:
提問語至少要認出四個步驟、至少要掃過四十個步驟。標記位置那一條原本把三種合法位置寫成
`/^#{2,3} /`,那條會把「### 沒編號的標題 〔可委派〕」也放過去,改成三條互斥的規則。

helpers 新增 promptSteps(名字、有沒有標記、內文),promptStep 改建在它上面,
順帶讓步驟標題容得下後綴。

議題 #58

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 09:12:08 +08:00
co-authored by Claude Opus 5
parent 84cd8fc41f
commit f61ef3fa78
2 changed files with 258 additions and 7 deletions
+38 -7
View File
@@ -66,20 +66,51 @@ export function assertNeutralPrompt(prompt, command) {
}
}
/** 可委派的標記。寫成標題後綴,不是 emoji 也不是 HTML 註解——那兩種模型讀不穩。 */
export const DELEGATABLE = '〔可委派〕';
/**
* 正本裡的每一個編號步驟:名字、有沒有被標成可委派、以及它的內文。
*
* 步驟的界線是下一個 `##` 或 `###` 標題;段落標題(`## 第二段…`)不算步驟,
* 但會把前一步收尾,否則一段的最後一步會把整個段落的收場白都吃進來。
* @param {string} prompt 正本內容
* @returns {{name: string, marked: boolean, body: string}[]}
*/
export function promptSteps(prompt) {
const lines = prompt.split('\n');
// 先收齊所有標題的行號,每一步的結尾就是它後面最近的那一個
const 標題行 = [];
lines.forEach((line, at) => {
if (/^#{2,3} /.test(line)) 標題行.push(at);
});
return 標題行
.map((at, i) => ({ at, 到: 標題行[i + 1] ?? lines.length }))
.filter(({ at }) => /^### \d+\. /.test(lines[at]))
.map(({ at, 到 }) => {
const raw = /^### \d+\. (.+)$/.exec(lines[at])[1].trim();
const marked = raw.endsWith(DELEGATABLE);
return {
name: (marked ? raw.slice(0, -DELEGATABLE.length) : raw).trim(),
marked,
body: lines.slice(at, 到).join('\n'),
};
});
}
/**
* 取出正本裡某一個編號步驟的內容,**以名字取而不是以編號取**。
* 步驟會增刪、編號會整批位移,名字不會;用編號寫的測試會在別人插一步時無聲地
* 框到另一段內容上,而那種失敗看起來像是正本掉了東西。
* @param {string} prompt 正本內容
* @param {string} name 步驟名,例如 '產生圖解版總覽'
* @returns {string} 該步驟的標題與內文,到下一個 `### ` 為止
* @param {string} name 步驟名,不含可委派後綴
* @returns {string} 該步驟的標題與內文,到下一個 `##` 或 `###` 標題為止
*/
export function promptStep(prompt, name) {
const start = prompt.search(new RegExp(`^### \\d+\\. ${name}$`, 'm'));
assert.ok(start >= 0, `正本裡找不到「${name}」這一步`);
const rest = prompt.slice(start);
const end = rest.slice(1).search(/^### /m);
return end === -1 ? rest : rest.slice(0, end + 1);
const step = promptSteps(prompt).find((one) => one.name === name);
assert.ok(step, `正本裡找不到「${name}」這一步`);
return step.body;
}
/**