From ded0e75285e41ef13943af254e1180877954e67a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 12:55:01 +0800 Subject: [PATCH] =?UTF-8?q?test(issue-extract):=20=E4=BB=A5=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E8=AE=8A=E9=AB=94=E8=A6=86=E8=93=8B=E6=8A=BD=E5=8F=96?= =?UTF-8?q?=E5=A5=91=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解析是整條鏈的上游,解析錯則下游全錯,所以模板變體餵得比別處雜:缺段落、 段落在但列表是空的、checkbox 已勾與未勾、中英混排、編號清單、名詞表只有 表頭、body 全空、出現契約外的段落。 留言的部分驗兩件事:未整併則數只算沒有 +1 的,以及留言內容一個字都不得出現 在輸出裡——後者用整份 stdout 做子字串比對,比逐欄檢查更難繞過。 Co-Authored-By: Claude Opus 5 (1M context) --- test/issue-extract.test.js | 352 +++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 test/issue-extract.test.js diff --git a/test/issue-extract.test.js b/test/issue-extract.test.js new file mode 100644 index 0000000..7af19bc --- /dev/null +++ b/test/issue-extract.test.js @@ -0,0 +1,352 @@ +/** + * 需求議題的抽取契約。 + * + * 這是整條鏈的上游:解析錯則下游全錯,所以模板變體要餵得夠雜——缺段落、 + * 列表空的、checkbox 已勾、中英混排、名詞表只有表頭。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runScript } from './helpers/run-script.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const INDEX = 7; + +/** 一顆套好模板、九段俱全的需求議題 */ +const FULL_BODY = `## 總覽 + +把一段口語需求變成結構化議題,讓開發者不必再來問細節。 + +圖解版總覽:https://claude.ai/artifact/abc + +## 背景 + +需求目前寫成散文,下游每次都要人重讀一遍。 + +## 目標 + +- 需求議題可被下游腳本機讀 +- 建立議題的時間從 30 分鐘降到 5 分鐘 + +## 非目標 + +- 不處理工作包的拆解 +- 不支援 Gitea 以外的議題系統 + +## 領域名詞表 + +| 名詞 | 定義 | +| --- | --- | +| 需求議題 | 描述一項需求的 Gitea issue | +| 工作包 | 從需求拆出的可獨立完成的單位 | + +## 流程圖 + +\`\`\`mermaid +flowchart TD + A[收集輸入] --> B[逐項詢問] + B --> C[產生議題] +\`\`\` + +## 驗收標準 + +- [ ] 輸入可為自由文字或議題編號 +- [x] 缺漏時逐項詢問 +- 產出議題套用固定模板 + +## 影響範圍 + +- plugins/tea-sdlc + +## 未決事項 + +- 標籤體系由誰維護尚未決定 +`; + +function routes(overrides = {}, { body = FULL_BODY, comments = [] } = {}) { + const base = healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { + status: 200, + body: { + number: INDEX, + title: '以 sdlc-plan 把口語需求轉成結構化需求議題', + html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`, + body, + labels: [{ id: 55, name: 'ready-for-agent' }], + }, + }, + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { + status: 200, + body: comments.map((c, i) => ({ id: 100 + i, body: c.body })), + }, + ...overrides, + }); + // 每則留言各自的 reaction 端點 + comments.forEach((c, i) => { + base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = { + status: 200, + body: (c.reactions ?? []).map((content) => ({ content })), + }; + }); + return { ...base, ...overrides }; +} + +const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options)); + +const run = (args, stub) => + runScript('issue-extract.js', ['--repo', REPO, '--index', String(INDEX), ...args], { + env: envFor(stub), + }); + +// ── 完整議題 ─────────────────────────────────────────────────────── + +test('抽出契約上的每一個欄位', async (t) => { + const stub = await withStub(t); + + const { code, json } = await run([], stub); + + assert.equal(code, 0); + assert.deepEqual(Object.keys(json.data).sort(), [ + 'index', 'labels', 'title', 'url', + '影響範圍', '未決事項', '未處理留言數', '流程圖', + '目標', '總覽', '背景', '名詞表', '非目標', '驗收標準', + ].sort()); +}); + +test('議題本身的識別資訊原樣帶出', async (t) => { + const stub = await withStub(t); + + const { json } = await run([], stub); + + assert.equal(json.data.index, INDEX); + assert.equal(json.data.url, `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`); + assert.equal(json.data.title, '以 sdlc-plan 把口語需求轉成結構化需求議題'); + assert.deepEqual(json.data.labels, ['ready-for-agent']); +}); + +test('文字型段落回傳整段內容', async (t) => { + const stub = await withStub(t); + + const { json } = await run([], stub); + + assert.match(json.data.總覽, /把一段口語需求變成結構化議題/); + assert.match(json.data.總覽, /圖解版總覽:https/, '總覽整段帶出,不丟掉網頁連結'); + assert.equal(json.data.背景, '需求目前寫成散文,下游每次都要人重讀一遍。'); +}); + +test('列表型段落回傳去掉項目符號的字串陣列', async (t) => { + const stub = await withStub(t); + + const { json } = await run([], stub); + + assert.deepEqual(json.data.目標, [ + '需求議題可被下游腳本機讀', + '建立議題的時間從 30 分鐘降到 5 分鐘', + ]); + assert.deepEqual(json.data.非目標, ['不處理工作包的拆解', '不支援 Gitea 以外的議題系統']); + assert.deepEqual(json.data.影響範圍, ['plugins/tea-sdlc']); + assert.deepEqual(json.data.未決事項, ['標籤體系由誰維護尚未決定']); +}); + +test('名詞表解析成 term 與 def,表頭與分隔列不算一筆', async (t) => { + const stub = await withStub(t); + + const { json } = await run([], stub); + + assert.deepEqual(json.data.名詞表, [ + { term: '需求議題', def: '描述一項需求的 Gitea issue' }, + { term: '工作包', def: '從需求拆出的可獨立完成的單位' }, + ]); +}); + +test('流程圖原樣帶出,連圍欄一起', async (t) => { + const stub = await withStub(t); + + const { json } = await run([], stub); + + assert.match(json.data.流程圖, /^```mermaid/); + assert.match(json.data.流程圖, /flowchart TD/); + assert.match(json.data.流程圖, /```$/); +}); + +// ── 模板變體 ─────────────────────────────────────────────────────── + +test('checkbox 已勾或未勾都只留文字,不留標記', async (t) => { + const stub = await withStub(t); + + const { json } = await run([], stub); + + assert.deepEqual(json.data.驗收標準, [ + '輸入可為自由文字或議題編號', + '缺漏時逐項詢問', + '產出議題套用固定模板', + ]); +}); + +test('缺段落回傳空值而不是報錯', async (t) => { + const body = '## 總覽\n\n只有總覽的議題。\n'; + const stub = await withStub(t, {}, { body }); + + const { code, json } = await run([], stub); + + assert.equal(code, 0); + assert.equal(json.data.總覽, '只有總覽的議題。'); + assert.equal(json.data.背景, ''); + assert.equal(json.data.流程圖, ''); + assert.deepEqual(json.data.目標, []); + assert.deepEqual(json.data.名詞表, []); + assert.deepEqual(json.data.驗收標準, []); +}); + +test('段落在但列表是空的,回傳空陣列', async (t) => { + const body = '## 目標\n\n## 非目標\n\n- 只有這一項\n'; + const stub = await withStub(t, {}, { body }); + + const { json } = await run([], stub); + + assert.deepEqual(json.data.目標, []); + assert.deepEqual(json.data.非目標, ['只有這一項']); +}); + +test('名詞表只有表頭時回傳空陣列', async (t) => { + const body = '## 領域名詞表\n\n| 名詞 | 定義 |\n| --- | --- |\n'; + const stub = await withStub(t, {}, { body }); + + const { json } = await run([], stub); + + assert.deepEqual(json.data.名詞表, []); +}); + +test('中英混排與行內標記都照原樣留著', async (t) => { + const body = '## 目標\n\n- 讓 `issue-extract` 的 output 可被 downstream 直接使用\n- 支援 CJK 與 ASCII 混排\n'; + const stub = await withStub(t, {}, { body }); + + const { json } = await run([], stub); + + assert.deepEqual(json.data.目標, [ + '讓 `issue-extract` 的 output 可被 downstream 直接使用', + '支援 CJK 與 ASCII 混排', + ]); +}); + +test('編號清單與符號清單一視同仁', async (t) => { + const body = '## 驗收標準\n\n1. 第一條\n2. 第二條\n'; + const stub = await withStub(t, {}, { body }); + + const { json } = await run([], stub); + + assert.deepEqual(json.data.驗收標準, ['第一條', '第二條']); +}); + +test('議題完全沒有內容時不炸,所有欄位為空', async (t) => { + const stub = await withStub(t, {}, { body: '' }); + + const { code, json } = await run([], stub); + + assert.equal(code, 0); + assert.equal(json.data.總覽, ''); + assert.deepEqual(json.data.目標, []); +}); + +test('不認得的段落不影響其他段落', async (t) => { + const body = '## 總覽\n\n有效內容。\n\n## 附錄\n\n- 不在契約裡的段落\n\n## 目標\n\n- 仍然抓得到\n'; + const stub = await withStub(t, {}, { body }); + + const { json } = await run([], stub); + + assert.equal(json.data.總覽, '有效內容。'); + assert.deepEqual(json.data.目標, ['仍然抓得到']); +}); + +// ── 留言:只數不讀 ───────────────────────────────────────────────── + +test('未處理留言數只算沒有 +1 標記的留言', async (t) => { + const stub = await withStub(t, {}, { + comments: [ + { body: '這則已經整併過了', reactions: ['+1'] }, + { body: '這則還沒', reactions: [] }, + { body: '這則有別的 reaction 但不是 +1', reactions: ['heart'] }, + ], + }); + + const { json } = await run([], stub); + + assert.equal(json.data.未處理留言數, 2); +}); + +test('沒有留言時為 0', async (t) => { + const stub = await withStub(t); + + const { json } = await run([], stub); + + assert.equal(json.data.未處理留言數, 0); +}); + +test('只讀 body:留言內容一個字都不出現在輸出裡', async (t) => { + const stub = await withStub(t, {}, { + comments: [{ body: '留言裡提到的決策不該被抽出來', reactions: [] }], + }); + + const { stdout } = await run([], stub); + + assert.equal(stdout.includes('留言裡提到的決策'), false); +}); + +// ── 錯誤 ─────────────────────────────────────────────────────────── + +test('議題不存在時回傳可區分的錯誤碼', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } }, + }); + + const { code, json } = await run([], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'ISSUE_NOT_FOUND'); + assert.match(json.error.message, new RegExp(String(INDEX))); +}); + +test('沒有讀取權時的錯誤碼與「議題不存在」分得開', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 403, body: { message: 'forbidden' } }, + }); + + const { json } = await run([], stub); + + assert.equal(json.error.code, 'NO_READ_ACCESS'); +}); + +test('--index 不是正整數時擋在打 Gitea 之前', async (t) => { + const stub = await withStub(t); + + const { json } = await runScript('issue-extract.js', ['--repo', REPO, '--index', 'abc'], { + env: envFor(stub), + }); + + assert.equal(json.error.code, 'BAD_INDEX'); + assert.equal(stub.requests.length, 0); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => { + const stub = await withStub(t); + + const { code, json } = await run(['--dry-run'], stub); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [ + `GET /repos/${REPO}/issues/${INDEX}`, + `GET /repos/${REPO}/issues/${INDEX}/comments`, + ], + ); + assert.match( + json.data.note, + /reaction/, + '每則留言的 reaction 查詢無法事前列舉,預覽要說清楚而不是假裝沒有', + ); + assert.equal(stub.requests.length, 0); +});