/** * 需求議題底下的工作包清單。 * * 這一支存在的理由只有一個:使用者手上有一顆需求議題編號,要挑出底下的某一顆工作包。 * 所以測試集中在「挑得對不對」:認的是不是工作包抽取那一套關聯判準、PR 會不會混進來、 * 清單讀不讀得完。半份清單最危險——使用者會從缺了幾顆的清單裡挑,而且看不出缺了誰。 */ 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 REQUIREMENT = 7; /** 一顆工作包的 body:判準只在「關聯」段落那一行 */ function wpBody(requirement) { return `## 這個工作包在做什麼 一句話。 ## 待辦 - [ ] 做一件事 ## 關聯 需求議題:#${requirement} 估算人天:2 `; } function issue(index, { title = `工作包 ${index}`, body = wpBody(REQUIREMENT), ...rest } = {}) { return { number: index, title, body, state: 'open', html_url: `https://gitea.example/${REPO}/issues/${index}`, assignee: null, ...rest, }; } /** 假 Gitea:議題清單逐頁回,其餘走 healthy 預設 */ function routes(issues, overrides = {}) { return healthyRoutes(REPO, { [`GET /api/v1/repos/${REPO}/issues`]: (req) => { const page = Number(req.query.page ?? 1); const limit = Number(req.query.limit ?? 50); return { status: 200, body: issues.slice((page - 1) * limit, page * limit) }; }, ...overrides, }); } async function run(t, issues, args = []) { const stub = await withStubGitea(t, routes(issues)); const result = await runScript( 'wp-list.js', ['--repo', REPO, '--requirement', String(REQUIREMENT), ...args], { env: envFor(stub) }, ); return { stub, ...result }; } // ── 挑出哪幾顆 ───────────────────────────────────────────────────── test('只收關聯指回這顆需求議題的工作包', async (t) => { const { code, json } = await run(t, [ issue(9), issue(10, { body: wpBody(99) }), issue(11), ]); assert.equal(code, 0); assert.deepEqual( json.data.工作包.map((wp) => wp.index), [9, 11], ); assert.equal(json.data.數量, 2); assert.equal(json.data.需求議題, REQUIREMENT); }); test('沒有關聯段落的議題不算工作包', async (t) => { const { json } = await run(t, [issue(9), issue(12, { body: '## 總覽\n\n這是一顆需求議題。\n' })]); assert.deepEqual(json.data.工作包.map((wp) => wp.index), [9]); }); test('PR 不進清單,即使它的描述也寫了需求議題', async (t) => { // pr-create 產出的 PR 描述本來就有「需求議題:#N」那一行,光看 body 會把 PR 當成工作包 const { json } = await run(t, [ issue(9), issue(30, { title: 'feat: 某某', pull_request: { merged: false } }), ]); assert.deepEqual(json.data.工作包.map((wp) => wp.index), [9]); }); test('已關閉的工作包照樣列出,並帶上狀態與領取人', async (t) => { const { json } = await run(t, [ issue(9, { state: 'closed', assignee: { login: 'jeffery' } }), ]); assert.deepEqual(json.data.工作包, [ { index: 9, title: '工作包 9', url: `https://gitea.example/${REPO}/issues/9`, state: 'closed', assignee: 'jeffery', }, ]); }); test('依編號由小到大排,清單的順序不隨 Gitea 回傳順序飄動', async (t) => { const { json } = await run(t, [issue(14), issue(9), issue(11)]); assert.deepEqual(json.data.工作包.map((wp) => wp.index), [9, 11, 14]); }); test('一顆都沒有時回空清單而不是報錯', async (t) => { const { code, json } = await run(t, [issue(10, { body: wpBody(99) })]); assert.equal(code, 0); assert.deepEqual(json.data.工作包, []); assert.equal(json.data.數量, 0); }); test('逐頁讀完,不只讀第一頁', async (t) => { const many = Array.from({ length: 60 }, (_, i) => issue(i + 1)); const { json, stub } = await run(t, many); assert.equal(json.data.數量, 60); const listings = stub.requests.filter((r) => r.path === `/api/v1/repos/${REPO}/issues`); assert.ok(listings.length >= 2, `要翻到第二頁,實際只發了 ${listings.length} 次`); }); test('讀的是全部狀態的議題,不只 open', async (t) => { const { stub } = await run(t, [issue(9)]); const listing = stub.requests.find((r) => r.path === `/api/v1/repos/${REPO}/issues`); assert.equal(listing.query.state, 'all'); }); // ── 契約 ─────────────────────────────────────────────────────────── test('--dry-run 列出將發出的請求,且完全不碰 Gitea', async (t) => { const stub = await withStubGitea(t, routes([issue(9)])); const { code, json } = await runScript( 'wp-list.js', ['--repo', REPO, '--requirement', String(REQUIREMENT), '--dry-run'], { env: envFor(stub) }, ); assert.equal(code, 0); assert.equal(json.data.dryRun, true); assert.deepEqual(json.data.requests, [ { method: 'GET', path: `/repos/${REPO}/issues` }, ]); assert.equal(stub.requests.length, 0); }); test('缺 --requirement 時指名缺的是哪一個', async (t) => { const stub = await withStubGitea(t, routes([])); const { json } = await runScript('wp-list.js', ['--repo', REPO], { env: envFor(stub) }); assert.equal(json.error.code, 'MISSING_FLAG'); assert.match(json.error.message, /--requirement/); }); test('--requirement 不是正整數時擋下', async (t) => { const stub = await withStubGitea(t, routes([])); const { json } = await runScript('wp-list.js', ['--repo', REPO, '--requirement', 'abc'], { env: envFor(stub), }); assert.equal(json.error.code, 'BAD_INDEX'); }); test('議題多到翻不完時報錯,不回半份清單', async (t) => { // 每一頁都回滿頁,永遠不會結束:翻到上限就要大聲說讀不完 const stub = await withStubGitea( t, healthyRoutes(REPO, { [`GET /api/v1/repos/${REPO}/issues`]: (req) => ({ status: 200, body: Array.from({ length: Number(req.query.limit ?? 50) }, (_, i) => issue(i + 1)), }), }), ); const { code, json } = await runScript( 'wp-list.js', ['--repo', REPO, '--requirement', String(REQUIREMENT)], { env: envFor(stub) }, ); assert.equal(code, 1); assert.equal(json.error.code, 'WORK_PACKAGE_LIMIT'); });