test(wp-extract): 覆蓋契約欄位、模板變體、活狀態與 CRLF
解析是整條鏈的上游,解析錯則下游全錯,所以模板變體餵得雜一些:缺段落、巢狀驗收為空、 checkbox 已勾、中英混排、圍欄裡的假待辦、只寫一格的表格列。 另外釘住三件容易在日後鬆掉的事: - `raw` 逐行出現在原始 body 裡,包括 CRLF 的 body 連行尾的 \r 都留著,否則下游替換 時對不上原文。 - 相依與留言都逐頁讀完,不是只讀第一頁。 - --dry-run 預告的請求順序與實跑一致,且完全不碰 Gitea。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,654 @@
|
|||||||
|
/**
|
||||||
|
* 工作包議題的抽取契約。
|
||||||
|
*
|
||||||
|
* 與需求議題那一支(issue-extract)的差別在三件事,測試也集中在這三件事上:
|
||||||
|
* 1. 待辦是巢狀的,而且每一項都要帶回未經修改的 `raw` —— 下游靠它精確勾選 checkbox。
|
||||||
|
* 2. 介面契約是四欄表格,四欄都要留著。
|
||||||
|
* 3. 議題 body 以外還要回報三個活狀態:相依、領取人、碼錶。
|
||||||
|
*
|
||||||
|
* 模板變體照樣要餵得夠雜:缺段落、巢狀驗收為空、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 = 9;
|
||||||
|
|
||||||
|
/** 一顆套好模板、九段俱全的工作包議題 */
|
||||||
|
const FULL_BODY = `## 這個工作包在做什麼
|
||||||
|
|
||||||
|
讓實作階段的指令能從工作包議題取得它需要的一切,而不必吞下整份議題全文。
|
||||||
|
|
||||||
|
## 描述
|
||||||
|
|
||||||
|
做完之後,實作指令給一個編號就拿得到待辦與驗收,不必人再讀一遍議題。
|
||||||
|
|
||||||
|
## 架構圖
|
||||||
|
|
||||||
|
\`\`\`mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
實作指令->>wp-extract: 議題編號
|
||||||
|
wp-extract->>實作指令: 精簡 JSON
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 範圍邊界
|
||||||
|
|
||||||
|
- 不讀留言內容,只回報未處理則數
|
||||||
|
- 不負責勾選 checkbox,那是 issue-update 的事
|
||||||
|
|
||||||
|
## 介面契約
|
||||||
|
|
||||||
|
| 介面 | 產出者 | 消費者 | 形狀 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| wp-extract | 本工作包 | sdlc-feat | 單行 JSON |
|
||||||
|
| raw 欄位 | 本工作包 | issue-update | 原始 markdown 行 |
|
||||||
|
|
||||||
|
## 待辦
|
||||||
|
|
||||||
|
- [x] 解析九個段落
|
||||||
|
- [x] 缺段落回空值而不是報錯
|
||||||
|
- [ ] 圍欄裡的井字號不算標題
|
||||||
|
- [ ] 待辦解析成巢狀結構
|
||||||
|
- [ ] 每一項都帶未經修改的 raw
|
||||||
|
|
||||||
|
## 整體驗收
|
||||||
|
|
||||||
|
- [ ] 輸出欄位與契約完全一致
|
||||||
|
- [x] 模板變體各有測試案例並通過
|
||||||
|
|
||||||
|
## repo 列表
|
||||||
|
|
||||||
|
- plugins/tea-sdlc
|
||||||
|
|
||||||
|
## 關聯
|
||||||
|
|
||||||
|
需求議題:#1
|
||||||
|
估算人天:3
|
||||||
|
`;
|
||||||
|
|
||||||
|
function routes(overrides = {}, options = {}) {
|
||||||
|
const {
|
||||||
|
body = FULL_BODY,
|
||||||
|
comments = [],
|
||||||
|
assignee = null,
|
||||||
|
depends = [],
|
||||||
|
blocks = [],
|
||||||
|
stopwatches = [],
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const base = healthyRoutes(REPO, {
|
||||||
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
number: INDEX,
|
||||||
|
title: '建立工作包的抽取契約',
|
||||||
|
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
|
||||||
|
body,
|
||||||
|
assignee: assignee === null ? null : { login: assignee },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/dependencies`]: {
|
||||||
|
status: 200,
|
||||||
|
body: depends.map((number) => ({ number })),
|
||||||
|
},
|
||||||
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/blocks`]: {
|
||||||
|
status: 200,
|
||||||
|
body: blocks.map((number) => ({ number })),
|
||||||
|
},
|
||||||
|
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
|
||||||
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
||||||
|
status: 200,
|
||||||
|
body: comments.map((c, i) => ({ id: 100 + i, body: c.body })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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('wp-extract.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||||
|
env: envFor(stub),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 這顆議題上跑著的碼錶長什麼樣 */
|
||||||
|
const stopwatchHere = { issue_index: INDEX, repo_owner_name: 'plugins', repo_name: 'tea-sdlc' };
|
||||||
|
|
||||||
|
// ── 契約 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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', 'url', 'title', 'assignee', 'repos', '相依',
|
||||||
|
'需求議題', '描述', '架構圖', '範圍邊界', '介面契約',
|
||||||
|
'待辦', '整體驗收', '碼錶中', '未處理留言數',
|
||||||
|
].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, '建立工作包的抽取契約');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('需求議題從關聯段落解析成編號', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.需求議題, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('文字型段落回傳整段內容,架構圖連圍欄一起', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.match(json.data.描述, /實作指令給一個編號就拿得到待辦與驗收/);
|
||||||
|
assert.match(json.data.架構圖, /^```mermaid/);
|
||||||
|
assert.match(json.data.架構圖, /sequenceDiagram/);
|
||||||
|
assert.match(json.data.架構圖, /```$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('範圍邊界與 repo 列表回傳字串陣列', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.範圍邊界, [
|
||||||
|
'不讀留言內容,只回報未處理則數',
|
||||||
|
'不負責勾選 checkbox,那是 issue-update 的事',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(json.data.repos, ['plugins/tea-sdlc']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('整體驗收回傳字串陣列,勾選與否都只留文字', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.整體驗收, [
|
||||||
|
'輸出欄位與契約完全一致',
|
||||||
|
'模板變體各有測試案例並通過',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 介面契約:四欄都要留著 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test('介面契約保留四欄,表頭與分隔列不算一筆', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.介面契約, [
|
||||||
|
{ 介面: 'wp-extract', 產出者: '本工作包', 消費者: 'sdlc-feat', 形狀: '單行 JSON' },
|
||||||
|
{ 介面: 'raw 欄位', 產出者: '本工作包', 消費者: 'issue-update', 形狀: '原始 markdown 行' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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| 介面 | 產出者 | 消費者 | 形狀 |\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';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.介面契約, [
|
||||||
|
{ 介面: '無', 產出者: '', 消費者: '', 形狀: '' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 待辦:巢狀與 raw ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('待辦解析成巢狀結構,驗收掛在它自己的待辦底下', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.待辦.map((todo) => todo.text), [
|
||||||
|
'解析九個段落',
|
||||||
|
'待辦解析成巢狀結構',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(json.data.待辦[0].驗收.map((item) => item.text), [
|
||||||
|
'缺段落回空值而不是報錯',
|
||||||
|
'圍欄裡的井字號不算標題',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(json.data.待辦[1].驗收.map((item) => item.text), [
|
||||||
|
'每一項都帶未經修改的 raw',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('勾選狀態如實反映在 done 上,待辦與驗收各自獨立', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.待辦.map((todo) => todo.done), [true, false]);
|
||||||
|
assert.deepEqual(json.data.待辦[0].驗收.map((item) => item.done), [true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('每一項待辦與驗收都帶 raw,內容為未經修改的原始 markdown 行', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.待辦[0].raw, '- [x] 解析九個段落');
|
||||||
|
assert.equal(json.data.待辦[0].驗收[0].raw, ' - [x] 缺段落回空值而不是報錯');
|
||||||
|
assert.equal(json.data.待辦[1].raw, '- [ ] 待辦解析成巢狀結構');
|
||||||
|
assert.equal(json.data.待辦[1].驗收[0].raw, ' - [ ] 每一項都帶未經修改的 raw');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raw 逐行出現在原始 body 裡,下游才替換得到', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
const lines = FULL_BODY.split('\n');
|
||||||
|
for (const todo of json.data.待辦) {
|
||||||
|
assert.ok(lines.includes(todo.raw), `raw 不在 body 裡:${todo.raw}`);
|
||||||
|
for (const item of todo.驗收) {
|
||||||
|
assert.ok(lines.includes(item.raw), `raw 不在 body 裡:${item.raw}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('沒有驗收的待辦回傳空陣列,不是缺欄位', async (t) => {
|
||||||
|
const body = '## 待辦\n\n- [ ] 一項沒有驗收的待辦\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.待辦.length, 1);
|
||||||
|
assert.deepEqual(json.data.待辦[0].驗收, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('沒有 checkbox 的項目也收得到,done 為 false', async (t) => {
|
||||||
|
const body = '## 待辦\n\n- 忘了寫 checkbox 的待辦\n - 它的驗收\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.待辦[0].text, '忘了寫 checkbox 的待辦');
|
||||||
|
assert.equal(json.data.待辦[0].done, false);
|
||||||
|
assert.equal(json.data.待辦[0].raw, '- 忘了寫 checkbox 的待辦');
|
||||||
|
assert.deepEqual(json.data.待辦[0].驗收.map((i) => i.text), ['它的驗收']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('大寫的 [X] 也算勾選', async (t) => {
|
||||||
|
const body = '## 待辦\n\n- [X] 大寫也是勾選\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.待辦[0].done, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('巢狀超過一層時攤進同一項的驗收,不無聲吃掉內容', async (t) => {
|
||||||
|
const body = '## 待辦\n\n- [ ] 上層待辦\n - [ ] 它的驗收\n - [ ] 不該存在的第三層\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.待辦.length, 1);
|
||||||
|
assert.deepEqual(json.data.待辦[0].驗收.map((i) => i.text), [
|
||||||
|
'它的驗收',
|
||||||
|
'不該存在的第三層',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('沒有上層待辦的縮排項目升格成待辦,不被丟掉', async (t) => {
|
||||||
|
const body = '## 待辦\n\n - [ ] 開頭就縮排的項目\n- [ ] 後面才出現的上層\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.待辦.map((todo) => todo.text), [
|
||||||
|
'開頭就縮排的項目',
|
||||||
|
'後面才出現的上層',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('編號清單與符號清單一視同仁', async (t) => {
|
||||||
|
const body = '## 待辦\n\n1. [ ] 第一項\n2. [ ] 第二項\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.待辦.map((todo) => todo.text), ['第一項', '第二項']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.待辦.map((todo) => todo.text), ['真正的待辦']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('中英混排與行內標記都照原樣留著', async (t) => {
|
||||||
|
const body = '## 待辦\n\n- [ ] 讓 `wp-extract` 的 output 可被 downstream 直接使用\n - [ ] 支援 CJK 與 ASCII 混排\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.待辦[0].text, '讓 `wp-extract` 的 output 可被 downstream 直接使用');
|
||||||
|
assert.equal(json.data.待辦[0].驗收[0].text, '支援 CJK 與 ASCII 混排');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 模板變體 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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.需求議題, null);
|
||||||
|
assert.deepEqual(json.data.範圍邊界, []);
|
||||||
|
assert.deepEqual(json.data.介面契約, []);
|
||||||
|
assert.deepEqual(json.data.待辦, []);
|
||||||
|
assert.deepEqual(json.data.整體驗收, []);
|
||||||
|
assert.deepEqual(json.data.repos, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('關聯段落沒寫需求議題時為 null,估算那一行不會被誤讀成編號', async (t) => {
|
||||||
|
const body = '## 關聯\n\n估算人天:3\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.需求議題, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.待辦.map((todo) => todo.text), ['仍然抓得到']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CRLF:在 Gitea 網頁上編輯過的 body 就長這樣 ───────────────────
|
||||||
|
|
||||||
|
test('CRLF 的 body 照樣解析得出待辦、清單與表格', async (t) => {
|
||||||
|
// 瀏覽器送出 textarea 一律用 CRLF,議題只要被網頁編輯過就會變成這樣。
|
||||||
|
// 逐行切開後每一行都掛著 \r,正則若用 . 會整行比不中,清單靜靜變成空的。
|
||||||
|
const body = FULL_BODY.replace(/\n/g, '\r\n');
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.待辦.map((todo) => todo.text), [
|
||||||
|
'解析九個段落',
|
||||||
|
'待辦解析成巢狀結構',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(json.data.待辦[0].驗收.map((item) => item.text), [
|
||||||
|
'缺段落回空值而不是報錯',
|
||||||
|
'圍欄裡的井字號不算標題',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(json.data.範圍邊界, [
|
||||||
|
'不讀留言內容,只回報未處理則數',
|
||||||
|
'不負責勾選 checkbox,那是 issue-update 的事',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(json.data.repos, ['plugins/tea-sdlc']);
|
||||||
|
assert.equal(json.data.介面契約.length, 2);
|
||||||
|
assert.equal(json.data.需求議題, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CRLF 的 raw 連行尾的 \\r 都留著,替換才對得上原文', async (t) => {
|
||||||
|
const body = FULL_BODY.replace(/\n/g, '\r\n');
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
const lines = body.split('\n');
|
||||||
|
assert.equal(json.data.待辦[0].raw, '- [x] 解析九個段落\r');
|
||||||
|
assert.ok(lines.includes(json.data.待辦[0].raw));
|
||||||
|
assert.ok(lines.includes(json.data.待辦[0].驗收[0].raw));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── body 以外的活狀態 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('相依反映 Gitea 上實際的 blocks 與 depends', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { depends: [7], blocks: [11, 12] });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.相依, { depends: [7], blocks: [11, 12] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('沒有相依時兩邊都是空陣列', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.相依, { depends: [], blocks: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assignee 帶出領取人的帳號', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { assignee: 'jiantw83' });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.assignee, 'jiantw83');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('沒人領取時 assignee 為 null', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.assignee, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('碼錶跑在這顆議題上時為 true', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { stopwatches: [stopwatchHere] });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.碼錶中, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('碼錶跑在別顆議題上時為 false', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, {
|
||||||
|
stopwatches: [{ ...stopwatchHere, issue_index: INDEX + 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.碼錶中, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同編號但不同 repo 的碼錶不算數', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, {
|
||||||
|
stopwatches: [{ ...stopwatchHere, repo_name: '別的專案' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.碼錶中, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('沒有任何碼錶時為 false', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.碼錶中, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 留言:只數不讀 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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('只讀 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('wp-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}/dependencies`,
|
||||||
|
`GET /repos/${REPO}/issues/${INDEX}/blocks`,
|
||||||
|
'GET /user/stopwatches',
|
||||||
|
`GET /repos/${REPO}/issues/${INDEX}/comments`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.match(json.data.note, /reaction/);
|
||||||
|
assert.equal(stub.requests.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 分頁 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
assert.equal(json.data.未處理留言數, 70);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('相依逐頁讀完:半份清單會讓下游把順序排錯', async (t) => {
|
||||||
|
const page1 = Array.from({ length: 50 }, (_, i) => ({ number: 1000 + i }));
|
||||||
|
const page2 = [{ number: 2000 }];
|
||||||
|
|
||||||
|
const stub = await withStub(t, {
|
||||||
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/dependencies`]: (req) => ({
|
||||||
|
status: 200,
|
||||||
|
body: req.query.page === '1' ? page1 : page2,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.相依.depends.length, 51);
|
||||||
|
assert.equal(json.data.相依.depends.at(-1), 2000);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user