/** * 勾選待辦:以抽取契約給的 `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/); });