diff --git a/test/comments-merge.test.js b/test/comments-merge.test.js new file mode 100644 index 0000000..9a14cd9 --- /dev/null +++ b/test/comments-merge.test.js @@ -0,0 +1,335 @@ +/** + * 把留言裡的決策整併回議題描述。 + * + * 兩件事錯了都很安靜,所以測試集中在這裡: + * + * 1. **局部更新。** 只換指定那一段,其餘一字不動。整份重寫會把別人在其他段落的 + * 編輯一起蓋掉,而議題的編輯紀錄沒有人會去比對。 + * 2. **標記只給真的整併進去的那幾則。** 略過的要保持未標記,下次才會再被提出來; + * 而描述沒寫成功就不該標記——標了就等於這則再也不會被看到。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runScript, tmpRoot } from './helpers/run-script.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const INDEX = 7; + +/** 一份有多個段落的需求議題 */ +const BODY = `## 總覽 + +把一段口語需求變成結構化議題。 + +## 背景 + +需求目前寫成散文。 + +## 目標 + +- 需求議題可被下游腳本機讀 +- 建立議題的時間從 30 分鐘降到 5 分鐘 + +## 非目標 + +- 不處理工作包的拆解 +`; + +/** 把段落內容寫成檔案,回傳路徑 */ +function contentFile(name, content) { + mkdirSync(tmpRoot, { recursive: true }); + const path = join(tmpRoot, `merge-${name}-${process.hrtime.bigint()}.md`); + writeFileSync(path, content); + return path; +} + +function routes(overrides = {}, { body = BODY, comments = [101, 102] } = {}) { + const base = healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { + status: 200, + body: { number: INDEX, title: '以 sdlc-plan 轉成結構化需求議題', body, html_url: `https://x/${INDEX}` }, + }, + [`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: (req) => ({ + status: 200, + body: { number: INDEX, ...req.body, html_url: `https://x/${INDEX}` }, + }), + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { + status: 200, + body: comments.map((id) => ({ id, body: `留言 ${id}`, user: { login: 'someone' } })), + }, + }); + for (const id of comments) { + base[`POST /api/v1/repos/${REPO}/issues/comments/${id}/reactions`] = { status: 201, body: {} }; + } + return { ...base, ...overrides }; +} + +const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options)); + +const run = (args, stub) => + runScript('comments-merge.js', ['--repo', REPO, '--index', String(INDEX), ...args], { + env: envFor(stub), + }); + +const reactions = (stub) => + stub.requests.filter((r) => r.method === 'POST' && r.path.includes('/reactions')); + +// ── 局部更新 ─────────────────────────────────────────────────────── + +test('只換指定那一段,其餘一字不動', async (t) => { + const stub = await withStub(t); + const file = contentFile('goals', '- 需求議題可被下游腳本機讀\n- 建立議題的時間降到 5 分鐘\n- 名詞表由需求提出者維護\n'); + + const { code, json } = await run( + ['--section', '目標', '--content-file', file, '--merged', '101'], + stub, + ); + + assert.equal(code, 0, JSON.stringify(json)); + const written = patchOf(stub).body.body; + assert.match(written, /- 名詞表由需求提出者維護/, '新內容要寫進去'); + assert.match(written, /## 總覽\n\n把一段口語需求變成結構化議題。/, '總覽原封不動'); + assert.match(written, /## 背景\n\n需求目前寫成散文。/, '背景原封不動'); + assert.match(written, /## 非目標\n\n- 不處理工作包的拆解/, '非目標原封不動'); +}); + +test('段落標題本身不動,只換它底下的內容', async (t) => { + const stub = await withStub(t); + const file = contentFile('keep-heading', '- 換掉的內容\n'); + + await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub); + + const written = patchOf(stub).body.body; + assert.equal((written.match(/^## 目標$/gm) ?? []).length, 1, '標題只有一個,沒有被複製或刪掉'); + assert.match(written, /## 目標\n\n- 換掉的內容\n\n## 非目標/); +}); + +test('段落之間的空行維持原本的樣子', async (t) => { + const stub = await withStub(t); + const file = contentFile('spacing', '- 甲\n'); + + await run(['--section', '背景', '--content-file', file, '--merged', '101'], stub); + + assert.match(patchOf(stub).body.body, /## 背景\n\n- 甲\n\n## 目標/); +}); + +test('最後一段也換得掉', async (t) => { + const stub = await withStub(t); + const file = contentFile('last', '- 也不處理權限\n'); + + await run(['--section', '非目標', '--content-file', file, '--merged', '101'], stub); + + const written = patchOf(stub).body.body; + assert.match(written, /## 非目標\n\n- 也不處理權限/); + assert.equal(written.includes('不處理工作包的拆解'), false, '舊內容要被換掉'); +}); + +test('段落不存在時擋下,不把內容補到別的地方去', async (t) => { + const stub = await withStub(t); + const file = contentFile('missing', '- 內容\n'); + + const { code, json } = await run( + ['--section', '沒有這一段', '--content-file', file, '--merged', '101'], + stub, + ); + + assert.equal(code, 1); + assert.equal(json.error.code, 'SECTION_NOT_FOUND'); + assert.equal(patchOf(stub), undefined); + assert.deepEqual(reactions(stub), [], '沒寫進去就不該標記'); +}); + +test('圍欄裡的假標題不算段落', async (t) => { + const body = '## 流程圖\n\n```\n## 目標\n這不是段落\n```\n\n## 目標\n\n- 真的目標\n'; + const stub = await withStub(t, {}, { body }); + const file = contentFile('fenced', '- 換掉的目標\n'); + + await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub); + + const written = patchOf(stub).body.body; + assert.match(written, /```\n## 目標\n這不是段落\n```/, '圍欄裡的內容原封不動'); + assert.match(written, /## 目標\n\n- 換掉的目標/); +}); + +// ── 標記:只給真的整併進去的 ─────────────────────────────────────── + +test('只標記 --merged 列出的那幾則', async (t) => { + const stub = await withStub(t, {}, { comments: [101, 102, 103] }); + const file = contentFile('partial', '- 內容\n'); + + const { json } = await run( + ['--section', '目標', '--content-file', file, '--merged', '101,103'], + stub, + ); + + assert.deepEqual(json.data.已標記, [101, 103]); + assert.deepEqual( + reactions(stub).map((r) => r.path), + [ + `/api/v1/repos/${REPO}/issues/comments/101/reactions`, + `/api/v1/repos/${REPO}/issues/comments/103/reactions`, + ], + '沒被整併的 102 要保持未標記,下次才會再被提出來', + ); +}); + +test('標記送的是 +1', async (t) => { + const stub = await withStub(t); + const file = contentFile('thumb', '- 內容\n'); + + await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub); + + assert.deepEqual(reactions(stub)[0].body, { content: '+1' }); +}); + +test('先寫描述再標記:標記是「這則已經收進去了」的結論', async (t) => { + const stub = await withStub(t); + const file = contentFile('order', '- 內容\n'); + + await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub); + + const writes = stub.requests + .filter((r) => r.method !== 'GET' && !r.path.endsWith('/issues/0')) + .map((r) => r.path); + assert.ok( + writes.indexOf(`/api/v1/repos/${REPO}/issues/${INDEX}`) + < writes.indexOf(`/api/v1/repos/${REPO}/issues/comments/101/reactions`), + ); +}); + +test('描述寫入失敗時不標記:標了就等於這則再也不會被看到', async (t) => { + const stub = await withStub(t, { + [`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 403, body: { message: 'forbidden' } }, + }); + const file = contentFile('fail', '- 內容\n'); + + const { code } = await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub); + + assert.equal(code, 1); + assert.deepEqual(reactions(stub), []); +}); + +test('--merged 指到議題上沒有的留言時擋下', async (t) => { + const stub = await withStub(t, {}, { comments: [101] }); + const file = contentFile('badid', '- 內容\n'); + + const { json } = await run( + ['--section', '目標', '--content-file', file, '--merged', '101,999'], + stub, + ); + + assert.equal(json.error.code, 'COMMENT_NOT_FOUND'); + assert.match(json.error.message, /999/); + assert.equal(patchOf(stub), undefined); +}); + +// ── 冪等 ─────────────────────────────────────────────────────────── + +test('內容與現況相同時不重寫描述,但該標記的還是要標', async (t) => { + // 重跑常常是因為上一輪標記那一步斷掉了 + const stub = await withStub(t); + const file = contentFile('same', '- 需求議題可被下游腳本機讀\n- 建立議題的時間從 30 分鐘降到 5 分鐘\n'); + + const { code, json } = await run( + ['--section', '目標', '--content-file', file, '--merged', '101'], + stub, + ); + + assert.equal(code, 0, JSON.stringify(json)); + assert.equal(json.data.描述已更新, false); + assert.equal(patchOf(stub), undefined, '沒變就不要在議題上留一筆空的編輯'); + assert.equal(reactions(stub).length, 1, '標記照舊'); +}); + +// ── 輸入 ─────────────────────────────────────────────────────────── + +test('內容是空的時候擋下:整併不該把一段清空', async (t) => { + const stub = await withStub(t); + const file = contentFile('empty', ' \n'); + + const { json } = await run( + ['--section', '目標', '--content-file', file, '--merged', '101'], + stub, + ); + + assert.equal(json.error.code, 'FILE_EMPTY'); + assert.equal(patchOf(stub), undefined); +}); + +test('內容檔不存在時回可區分的錯誤碼', async (t) => { + const stub = await withStub(t); + + const { json } = await run( + ['--section', '目標', '--content-file', join(tmpRoot, '不存在.md'), '--merged', '101'], + stub, + ); + + assert.equal(json.error.code, 'FILE_NOT_FOUND'); +}); + +test('沒有 --merged 時擋下:整併卻不標記,下次會重複處理同一則', async (t) => { + const stub = await withStub(t); + const file = contentFile('nomerged', '- 內容\n'); + + const { json } = await runScript('comments-merge.js', [ + '--repo', REPO, '--index', String(INDEX), '--section', '目標', '--content-file', file, + ], { env: envFor(stub) }); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--merged/); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出改完的描述與將標記的留言,但不寫入', async (t) => { + const stub = await withStub(t); + const file = contentFile('dry', '- 換掉的目標\n'); + + const { code, json } = await run( + ['--section', '目標', '--content-file', file, '--merged', '101,102', '--dry-run'], + stub, + ); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.match(json.data.requests[0].body.body, /- 換掉的目標/); + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [ + `PATCH /repos/${REPO}/issues/${INDEX}`, + `POST /repos/${REPO}/issues/comments/101/reactions`, + `POST /repos/${REPO}/issues/comments/102/reactions`, + ], + ); + assert.equal(patchOf(stub), undefined); + assert.deepEqual(reactions(stub), []); +}); + +test('--dry-run 遇到段落不存在一樣報錯,不會等到實跑才發現', async (t) => { + const stub = await withStub(t); + const file = contentFile('dry-bad', '- 內容\n'); + + const { json } = await run( + ['--section', '沒有這一段', '--content-file', file, '--merged', '101', '--dry-run'], + stub, + ); + + assert.equal(json.error.code, 'SECTION_NOT_FOUND'); +}); + +test('--dry-run 在內容沒變時不預告 PATCH', async (t) => { + const stub = await withStub(t); + const file = contentFile('dry-same', '- 需求議題可被下游腳本機讀\n- 建立議題的時間從 30 分鐘降到 5 分鐘\n'); + + const { json } = await run( + ['--section', '目標', '--content-file', file, '--merged', '101', '--dry-run'], + stub, + ); + + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [`POST /repos/${REPO}/issues/comments/101/reactions`], + ); +});