/** * 需求議題的抽取契約。 * * 這是整條鏈的上游:解析錯則下游全錯,所以模板變體要餵得夠雜——缺段落、 * 列表空的、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); }); // ── 圍欄與表格的邊界(皆為 code review 抓出的實際缺陷,這裡封住回頭路)──── test('~~~ 圍欄裡的井字號不是標題', async (t) => { const body = '## 流程圖\n\n~~~\n## 這不是標題\n~~~\n\n## 目標\n\n- 真的目標\n'; const stub = await withStub(t, {}, { body }); const { json } = await run([], stub); assert.deepEqual(json.data.目標, ['真的目標']); assert.match(json.data.流程圖, /這不是標題/, '圍欄內容原樣留在流程圖段落裡'); }); test('圍欄裡的減號不是清單項', async (t) => { const body = '## 驗收標準\n\n```\n- 程式碼範例裡的假項目\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```\n~~~\n- 仍在圍欄內\n```\n\n- 圍欄外的項目\n'; const stub = await withStub(t, {}, { body }); const { json } = await run([], stub); assert.deepEqual(json.data.驗收標準, ['圍欄外的項目']); }); test('圍欄沒關就到結尾時,其後內容算在圍欄內(與 markdown 渲染一致)', async (t) => { const body = '## 流程圖\n\n```mermaid\nflowchart TD\n\n## 驗收標準\n\n- 被圍欄吃掉\n'; const stub = await withStub(t, {}, { body }); const { code, json } = await run([], stub); assert.equal(code, 0, '不該炸掉,只是內容歸屬不同'); assert.deepEqual(json.data.驗收標準, []); assert.match(json.data.流程圖, /驗收標準/); }); test('名詞表的欄位內容可以有逸脫的直線', async (t) => { const body = '## 領域名詞表\n\n| 名詞 | 定義 |\n| --- | --- |\n| a\\|b | 帶直線的名詞 |\n'; const stub = await withStub(t, {}, { body }); const { json } = await run([], stub); assert.deepEqual(json.data.名詞表, [{ term: 'a|b', def: '帶直線的名詞' }]); }); test('同一段落裡出現第二條分隔列時,不會變成一筆假名詞', async (t) => { const body = '## 領域名詞表\n\n| 名詞 | 定義 |\n| --- | --- |\n| 甲 | 一 |\n| --- | --- |\n| 乙 | 二 |\n'; const stub = await withStub(t, {}, { body }); const { json } = await run([], stub); assert.deepEqual(json.data.名詞表, [ { term: '甲', def: '一' }, { term: '乙', def: '二' }, ]); }); 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) => { // 第一頁滿 50 筆就得再翻一頁;第二頁不滿才收手 const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 200 + i })); const page2 = Array.from({ length: 20 }, (_, i) => ({ id: 300 + i })); const reactions = {}; for (const { id } of [...page1, ...page2]) { reactions[`GET /api/v1/repos/${REPO}/issues/comments/${id}/reactions`] = { status: 200, body: [] }; } const stub = await withStub(t, { [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: (req) => ({ status: 200, body: req.query.page === '1' ? page1 : page2, }), ...reactions, }); const { json } = await run([], stub); const commentPages = stub.requests.filter((r) => r.path.endsWith(`/issues/${INDEX}/comments`)); assert.deepEqual(commentPages.map((r) => r.query.page), ['1', '2']); assert.equal(json.data.未處理留言數, 70, '第二頁的留言也要算進來'); });