Files
tea-sdlc/test/issue-tick.test.js
jiantw83andClaude Opus 5 8ea6a2a8b3 test(逐項實作): 覆蓋勾選的五種危險、兩份規則正本與正本第二段
勾選的測試全部繞著「會不會改錯行」打轉,五種都是 code review 抓出來的實際缺陷:
圍欄裡長得像 checkbox 的那一行不會被改到、不同段落的同一句話靠 --section 分得開、
大寫 [X] 重跑是 no-op、方框後沒有空白照樣勾得到、沒有方框的項目給的是指路的錯誤
而不是謊報已勾過。

另外釘住抽取端與勾選端的一致性:wp-extract 交得出來的每一種 raw,--tick 都要收得下。

patchOf 收進 helpers——它先前在三個測試檔裡各有一份一模一樣的定義。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:39:54 +00:00

377 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 勾選待辦:以抽取契約給的 `raw` 做精確字串替換。
*
* 這一支的全部價值在「只動目標那一行」。改壞的代價很安靜——議題上的進度條會說謊,
* 而沒有人會去比對 body 的編輯紀錄。所以三種危險各有測試:
* - 同一句話在 body 裡出現兩次(巢狀待辦底下常有一模一樣的驗收,例如「加上測試」)
* - `raw` 對不上(議題被人改過,手上的抽取結果已經過期)
* - 已經勾過了(中斷後重跑)
* 前兩種寧可報錯也不猜,第三種要安靜地當作沒事。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const INDEX = 12;
/**
* 一份有巢狀待辦的工作包 body。刻意埋了三個地雷:
* - 兩項驗收的文字一模一樣(同一段落內,真的分不出來)
* - 架構圖是 fenced mermaid,裡面有一行長得像 checkbox
* - 整體驗收裡有一行與待辦完全相同(不同段落,靠 --section 分得出來)
*/
const BODY = `## 架構圖
\`\`\`mermaid
flowchart TD
A[讀議題] --> B[勾待辦]
- [ ] 解析九個段落
\`\`\`
## 待辦
- [ ] 解析九個段落
- [ ] 缺段落回空值
- [ ] 加上測試
- [ ] 待辦解析成巢狀結構
- [ ] 加上測試
## 整體驗收
- [ ] 輸出欄位與契約完全一致
- [ ] 待辦解析成巢狀結構
`;
function routes(overrides = {}, { body = BODY } = {}) {
return healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: { number: INDEX, title: '逐項實作並即時勾選待辦', body, html_url: 'https://example.com/12' },
},
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: (req) => ({
status: 200,
body: { number: INDEX, ...req.body, html_url: 'https://example.com/12' },
}),
...overrides,
});
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
/**
* 把「待辦」段落裡的某一行換成指定寫法。
* 不直接對整份 BODY 做 replace:圍欄裡那一行排在待辦之前,會被換掉的是它。
*/
function withTodoLine(from, to) {
const at = BODY.indexOf('## 待辦');
return BODY.slice(0, at) + BODY.slice(at).replace(from, to);
}
const run = (args, stub) =>
runScript('issue-update.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
env: envFor(stub),
});
// ── 精確替換 ───────────────────────────────────────────────────────
test('勾起指定的那一行,其餘一字不動', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
assert.equal(code, 0, JSON.stringify(json));
const body = patchOf(stub).body.body;
assert.match(body, /- \[x\] 解析九個段落/);
assert.equal(
body.replace('- [x] 解析九個段落', '- [ ] 解析九個段落'),
BODY,
'把那一個方框換回去之後,應該逐字等於原本的 body',
);
});
test('縮排的驗收項目也勾得到,縮排原樣保留', async (t) => {
const stub = await withStub(t);
const { code } = await run(['--tick', ' - [ ] 缺段落回空值', '--section', '待辦'], stub);
assert.equal(code, 0);
assert.match(patchOf(stub).body.body, /\n {2}- \[x\] 缺段落回空值\n/);
});
test('回報勾起來的是哪一行,讓呼叫端印進度', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
assert.equal(json.data.勾起的那一行, '- [x] 解析九個段落');
assert.equal(json.data.已經勾過, false);
});
// ── 文字重複時不誤傷 ───────────────────────────────────────────────
test('同一句話在 body 裡出現兩次時報錯,不賭第一個', async (t) => {
// 巢狀待辦底下常有一模一樣的驗收;猜錯的話,議題上的進度條會指著錯的那一項
const stub = await withStub(t);
const { code, json } = await run(['--tick', ' - [ ] 加上測試', '--section', '待辦'], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'RAW_AMBIGUOUS');
assert.match(json.error.message, /2/, '要說出它出現了幾次');
assert.equal(patchOf(stub), undefined, '分不出是哪一行就不要寫');
});
// ── raw 對不上 ─────────────────────────────────────────────────────
test('raw 不匹配時回錯誤,不盲改', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(['--tick', '- [ ] 這一行議題上沒有', '--section', '待辦'], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'RAW_NOT_FOUND');
assert.match(json.error.message, /重新抽取|過期/, '要指出手上的抽取結果可能過期了');
assert.equal(patchOf(stub), undefined);
});
test('差一個空白也算對不上:精確替換就是要精確', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
assert.equal(json.error.code, 'RAW_NOT_FOUND');
});
// ── 冪等:中斷後重跑 ───────────────────────────────────────────────
test('已經勾過的項目不再動它,也不發 PATCH', async (t) => {
const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落');
const stub = await withStub(t, {}, { body });
const { code, json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
assert.equal(code, 0, '重跑不該失敗,那會讓中斷後的接續變成人工作業');
assert.equal(json.data.已經勾過, true);
assert.equal(patchOf(stub), undefined, '沒有變化就不要在議題上留下一筆空的編輯');
});
test('直接給已勾的那一行也算數,同樣是 no-op', async (t) => {
const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落');
const stub = await withStub(t, {}, { body });
const { code, json } = await run(['--tick', '- [x] 解析九個段落', '--section', '待辦'], stub);
assert.equal(code, 0);
assert.equal(json.data.已經勾過, true);
});
test('大寫的 [X] 重跑時也是安靜的 no-op,不是 RAW_NOT_FOUND', async (t) => {
// [X] 是合法的 GFM,Gitea 會把它渲染成已勾,wp-extract 也回報 done:true。
// 比對時若只認小寫,中斷後重跑會硬失敗,而錯誤訊息還會誣指「議題被改過」。
const body = withTodoLine('- [ ] 待辦解析成巢狀結構', '- [X] 待辦解析成巢狀結構');
const stub = await withStub(t, {}, { body });
const { code, json } = await run(['--tick', '- [X] 待辦解析成巢狀結構', '--section', '待辦'], stub);
assert.equal(code, 0, JSON.stringify(json));
assert.equal(json.data.已經勾過, true);
assert.equal(patchOf(stub), undefined);
});
// ── 輸入驗證 ───────────────────────────────────────────────────────
test('--tick 的內容根本不是清單項時擋下', async (t) => {
// 「是清單項但忘了寫方框」是另一種情況,錯誤碼不同——那種要指路去議題上補
const stub = await withStub(t);
const { json } = await run(['--tick', '解析九個段落'], stub);
assert.equal(json.error.code, 'BAD_RAW');
assert.match(json.error.message, /清單項/);
});
test('--section 沒有配 --tick 時說清楚它沒有作用', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--section', '待辦', '--milestone', '第一階段'], stub);
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--tick/);
});
test('--tick 夾帶換行時擋下:一次只勾一行', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--tick', '- [ ] 甲\n- [ ] 乙'], stub);
assert.equal(json.error.code, 'BAD_RAW');
});
// ── 圍欄與段落:不誤傷、也不假歧義 ─────────────────────────────────
test('圍欄裡長得像 checkbox 的那一行不算,不會被改到', async (t) => {
// 工作包模板的架構圖就是一塊 fenced mermaid,裡面出現減號開頭的行是常態。
// issue-body.js 全檔的前提是「圍欄裡的東西不是內容」,勾選是唯一會寫回去的路徑,
// 漏掉這件事就會靜靜改壞圖。
const stub = await withStub(t);
const { code } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
assert.equal(code, 0);
const body = patchOf(stub).body.body;
const fence = body.slice(body.indexOf('```mermaid'), body.indexOf('## 待辦'));
assert.match(fence, /- \[ \] 解析九個段落/, '圍欄裡那一行要原封不動');
});
test('不同段落有同一行時,--section 分得出來', async (t) => {
// 待辦與整體驗收各有一行「待辦解析成巢狀結構」,限定段落就不該是歧義
const stub = await withStub(t);
const { code, json } = await run(['--tick', '- [ ] 待辦解析成巢狀結構', '--section', '待辦'], stub);
assert.equal(code, 0, JSON.stringify(json));
const body = patchOf(stub).body.body;
const todo = body.slice(body.indexOf('## 待辦'), body.indexOf('## 整體驗收'));
const overall = body.slice(body.indexOf('## 整體驗收'));
assert.match(todo, /- \[x\] 待辦解析成巢狀結構/, '待辦那一行要被勾起');
assert.match(overall, /- \[ \] 待辦解析成巢狀結構/, '整體驗收那一行不該被動到');
});
test('整體驗收段落也勾得到,各勾各的', async (t) => {
const stub = await withStub(t);
const { code } = await run(['--tick', '- [ ] 待辦解析成巢狀結構', '--section', '整體驗收'], stub);
assert.equal(code, 0);
const body = patchOf(stub).body.body;
const todo = body.slice(body.indexOf('## 待辦'), body.indexOf('## 整體驗收'));
assert.match(todo, /- \[ \] 待辦解析成巢狀結構/, '待辦那一行不該被動到');
assert.match(body.slice(body.indexOf('## 整體驗收')), /- \[x\] 待辦解析成巢狀結構/);
});
test('--section 指到不存在的段落時報錯,不退回掃全文', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '沒有這一段'], stub);
assert.equal(json.error.code, 'SECTION_NOT_FOUND');
});
test('沒給 --section 時掃全文,但圍欄照樣不算', async (t) => {
const body = '## 待辦\n\n```\n- [ ] 圍欄裡的假待辦\n```\n\n- [ ] 真正的待辦\n';
const stub = await withStub(t, {}, { body });
const { code } = await run(['--tick', '- [ ] 圍欄裡的假待辦'], stub);
assert.equal(code, 1, '圍欄裡的行不是內容,找不到才對');
assert.equal(patchOf(stub), undefined);
});
// ── 抽取端與勾選端要對得上 ─────────────────────────────────────────
test('方框後面沒有空白也勾得到:抽取端收得下的,勾選端就要收得下', async (t) => {
// parseChecklistItem 的文法允許 `- [ ]甲`,wp-extract 會照樣交出它的 raw;
// 勾選端若比抽取端嚴格,正本那句「一律用 wp-extract 給的 raw」就變成做不到的事
const body = '## 待辦\n\n- [ ]沒有空白的那一項\n';
const stub = await withStub(t, {}, { body });
const { code, json } = await run(['--tick', '- [ ]沒有空白的那一項', '--section', '待辦'], stub);
assert.equal(code, 0, JSON.stringify(json));
assert.match(patchOf(stub).body.body, /- \[x\]沒有空白的那一項/);
});
test('議題上那一項根本沒有 checkbox 時,錯誤要說清楚而不是謊報已勾過', async (t) => {
// wp-extract 會把 `- 忘了寫 checkbox` 當成一項待辦(done:false),
// 但那一行沒有方框可以換。這時要說「去議題上補成 checkbox」,不能回報「已經勾過」
const body = '## 待辦\n\n- 忘了寫 checkbox 的待辦\n';
const stub = await withStub(t, {}, { body });
const { code, json } = await run(['--tick', '- 忘了寫 checkbox 的待辦', '--section', '待辦'], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'NOT_A_CHECKBOX');
assert.match(json.error.message, /補/, '要告訴使用者去議題上把它補成 checkbox');
});
// ── 與既有欄位共存 ─────────────────────────────────────────────────
test('--tick 可以和別的欄位一起送,共用同一個 PATCH', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/milestones`]: { status: 200, body: [{ id: 3, title: '第一階段' }] },
});
const { code } = await run(
['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--milestone', '第一階段'],
stub,
);
assert.equal(code, 0);
const patch = patchOf(stub);
assert.match(patch.body.body, /- \[x\] 解析九個段落/);
assert.equal(patch.body.milestone, 3);
});
test('什麼都沒指定時仍然報 NOTHING_TO_UPDATE', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.equal(json.error.code, 'NOTHING_TO_UPDATE');
assert.match(json.error.message, /--tick/, '新欄位也要列進可用清單');
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出改完的 body,但不寫進去', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(
['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--dry-run'],
stub,
);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.match(json.data.requests[0].body.body, /- \[x\] 解析九個段落/);
assert.equal(patchOf(stub), undefined);
});
test('--dry-run 在已經勾過時要說「實跑不會發任何請求」', async (t) => {
// 試跑印出一個 PATCH、實跑卻什麼都不送,是最難查的那種落差
const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落');
const stub = await withStub(t, {}, { body });
const { json } = await run(
['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--dry-run'],
stub,
);
assert.equal(json.data.已經勾過, true);
assert.deepEqual(json.data.requests, [], '沒有東西要改,預告的請求就該是空的');
});
test('--dry-run 遇到分不清的 raw 一樣報錯,不會等到實跑才發現', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--tick', ' - [ ] 加上測試', '--section', '待辦', '--dry-run'], stub);
assert.equal(json.error.code, 'RAW_AMBIGUOUS');
});
// ── CRLF 的 body ───────────────────────────────────────────────────
test('CRLF 的 body 也勾得到,行尾的 \\r 不被吃掉', async (t) => {
// 議題只要在 Gitea 網頁上被編輯過就是 CRLF;wp-extract 交出的 raw 會連 \r 一起帶著
const body = BODY.replace(/\n/g, '\r\n');
const stub = await withStub(t, {}, { body });
const { code } = await run(['--tick', '- [ ] 解析九個段落\r', '--section', '待辦'], stub);
assert.equal(code, 0);
assert.match(patchOf(stub).body.body, /- \[x\] 解析九個段落\r\n/);
});