feat: 移除計時並停用報表預覽

This commit is contained in:
2026-09-21 17:41:44 +08:00
parent 5a259a95af
commit 7f723adff8
34 changed files with 100 additions and 6113 deletions
-312
View File
@@ -1,312 +0,0 @@
/**
* 領取工作包的鎖。
*
* 錶不在這一支起——它等工作樹建好之後才由 timer.js 起動,所以這裡連帶要驗
* 「一發起錶請求都沒有」:領取失敗或工作樹建不起來時,使用者不該被計一段
* 什麼都沒做的時間。
*
* 這一支的價值全在「什麼時候擋下來」:放行的路徑只有一條,擋的理由有四種,
* 而擋錯的代價是兩個人做同一件事、或是工時記到別顆議題上。所以決策表的四種狀態
* 各有測試,而且每一種都要驗「一個字都沒寫進 Gitea」——擋下來卻已經改了一半,
* 比直接放行更難收拾。
*/
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 = 11;
const ME = 'tester';
/** 議題上跑著的碼錶長什麼樣 */
const stopwatchOn = (index, repo = REPO) => ({
issue_index: index,
repo_owner_name: repo.split('/')[0],
repo_name: repo.split('/')[1],
});
function routes(overrides = {}, options = {}) {
const { assignees = [], labels = [], stopwatches = [], repoLabels } = options;
const base = healthyRoutes(REPO, {
'GET /api/v1/user': { status: 200, body: { login: ME } },
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: {
number: INDEX,
title: '以 sdlc-feat 領取工作包、起錶並備妥分支',
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
assignees: assignees.map((login) => ({ login })),
labels: labels.map((name, i) => ({ id: 60 + i, name })),
},
},
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 201, body: {} },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`]: { status: 200, body: [] },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`]: { status: 201, body: {} },
});
if (repoLabels !== undefined) {
base[`GET /api/v1/repos/${REPO}/labels`] = {
status: 200,
body: repoLabels.map((name, i) => ({ id: 55 + i, name })),
};
}
return { ...base, ...overrides };
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
const run = (args, stub) =>
runScript('claim.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
env: envFor(stub),
});
/**
* 會改動 Gitea 的請求;擋下來的情境裡這些一個都不該出現。
* 前置檢查對 `issues/0` 的那一發 PATCH 不算數——它是探權限用的,打在一顆不存在的議題上,
* 不會改動任何東西(見 lib.js 的 checkIssueWrite)。
*/
const writes = (stub) =>
stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0'));
// ── 決策表:無鎖 ───────────────────────────────────────────────────
test('沒有鎖時放行:設 assignee、貼進行中', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] });
const { code, json } = await run([], stub);
assert.equal(code, 0);
assert.equal(json.data.assignee, ME);
assert.deepEqual(json.data.labels, ['進行中']);
assert.equal(json.data.碼錶中, false, '鎖上好了,錶還沒起');
assert.equal(json.data.已認領過, false);
});
test('放行時只寫入鎖的那兩件事,一發起錶請求都沒有', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
await run([], stub);
assert.deepEqual(
writes(stub).map((r) => `${r.method} ${r.path}`),
[
`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`,
`POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`,
],
'錶等工作樹建好之後才由 timer.js 起動:建不起來就中止,不該已經計了時間',
);
});
test('assignee 送的是自己的帳號,標籤送的是 id 不是名字', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] });
await run([], stub);
const patch = writes(stub).find((r) => r.method === 'PATCH');
assert.deepEqual(patch.body.assignees, [ME]);
const label = writes(stub).find((r) => r.path.endsWith('/labels'));
assert.deepEqual(label.body.labels, [56], '進行中在假 repo 上的 id 是 56');
});
// ── 決策表:他人已認領 ─────────────────────────────────────────────
test('他人已認領時擋下,並指名是誰', async (t) => {
const stub = await withStub(t, {}, { assignees: ['someone-else'], repoLabels: ['進行中'] });
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'CLAIMED_BY_OTHER');
assert.match(json.error.message, /someone-else/);
assert.deepEqual(writes(stub), [], '擋下來就不該寫進任何東西');
});
test('自己在 assignee 裡但還有別人時,一樣擋', async (t) => {
const stub = await withStub(t, {}, { assignees: [ME, 'someone-else'], repoLabels: ['進行中'] });
const { json } = await run([], stub);
assert.equal(json.error.code, 'CLAIMED_BY_OTHER');
});
// ── 決策表:自己的碼錶在跑 ─────────────────────────────────────────
test('自己碼錶跑在本議題時擋下,要求先手動停錶', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [stopwatchOn(INDEX)],
repoLabels: ['進行中'],
});
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'STOPWATCH_ON_THIS_ISSUE');
assert.match(json.error.message, /停/, '要說清楚下一步是手動停錶');
assert.match(json.error.message, /工作樹/, '要明說停錶不會動到既有的工作樹');
assert.deepEqual(writes(stub), []);
});
test('自己碼錶跑在別的議題時擋下,並指出是哪一顆', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [stopwatchOn(7)],
repoLabels: ['進行中'],
});
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
assert.match(json.error.message, /#7/, '忘了停掉的是哪一顆,要指名');
assert.match(
json.error.message,
/停錶只停計時,不會動到任何既有的工作樹/,
'以為停錶等於放棄那顆工作包的人會寧可不停,工時就記到別顆去了',
);
assert.deepEqual(writes(stub), []);
});
test('別的 repo 上的同號碼錶也算自己有錶在跑', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [stopwatchOn(INDEX, 'plugins/別的專案')],
repoLabels: ['進行中'],
});
const { json } = await run([], stub);
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
assert.match(json.error.message, /別的專案/);
});
// ── 冪等:中斷後重跑 ───────────────────────────────────────────────
test('自己已認領但沒有錶時放行,並如實說這顆本來就是自己的', async (t) => {
const stub = await withStub(t, {}, { assignees: [ME], repoLabels: ['進行中'] });
const { code, json } = await run([], stub);
assert.equal(code, 0);
assert.equal(json.data.已認領過, true);
assert.equal(json.data.碼錶中, false, '重跑時鎖照樣補齊,錶則仍舊留到工作樹建好之後');
});
test('進行中標籤已經在議題上時不重複貼', async (t) => {
const stub = await withStub(t, {}, {
assignees: [ME],
labels: ['進行中'],
repoLabels: ['進行中'],
});
const { json } = await run([], stub);
assert.equal(json.data.已認領過, true);
assert.deepEqual(json.data.labels, ['進行中']);
assert.equal(
writes(stub).some((r) => r.path.endsWith('/labels')),
false,
'已經貼著的標籤不必再貼一次',
);
});
// ── 標籤:本 plugin 不自動建立標籤 ─────────────────────────────────
test('repo 上沒有進行中標籤時擋在寫入之前,並指出該去建哪一個', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent'] });
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'LABEL_NOT_FOUND');
assert.match(json.error.message, /進行中/);
assert.deepEqual(writes(stub), [], '標籤缺了就整件事不做,不要只設一半的鎖');
});
// ── 錯誤 ───────────────────────────────────────────────────────────
test('議題不存在時回傳可區分的錯誤碼', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
}, { repoLabels: ['進行中'] });
const { json } = await run([], stub);
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
});
test('--index 不是正整數時擋在打 Gitea 之前', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
const { json } = await runScript('claim.js', ['--repo', REPO, '--index', '0'], {
env: envFor(stub),
});
assert.equal(json.error.code, 'BAD_INDEX');
assert.equal(stub.requests.length, 0);
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出將發出的寫入,但一個字都不寫進去', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
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}`),
[
`PATCH /repos/${REPO}/issues/${INDEX}`,
`POST /repos/${REPO}/issues/${INDEX}/labels`,
],
);
assert.deepEqual(writes(stub), [], '預覽不得真的寫入');
});
test('--dry-run 會先讀現況:預覽出來的是這一顆實際的處境', async (t) => {
// 手寫一份固定的清單很容易跟實作走鐘,而且說不出「這顆已經是你的了」這種事
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
await run(['--dry-run'], stub);
const reads = stub.requests.filter((r) => r.method === 'GET').map((r) => r.path);
assert.ok(reads.includes('/api/v1/user'));
assert.ok(reads.includes(`/api/v1/repos/${REPO}/issues/${INDEX}`));
assert.ok(reads.includes('/api/v1/user/stopwatches'));
assert.ok(reads.includes(`/api/v1/repos/${REPO}/labels`));
});
test('--dry-run 略過已經做好的部分:鎖都在了就什麼都不必寫', async (t) => {
const stub = await withStub(t, {}, {
assignees: [ME],
labels: ['進行中'],
repoLabels: ['進行中'],
});
const { json } = await run(['--dry-run'], stub);
assert.deepEqual(
json.data.requests,
[],
'assignee 與標籤都已經到位,中斷重跑就是走到這裡;手寫一份固定的清單會謊報',
);
});
test('--dry-run 在鎖擋得住的情況下照樣擋,這才是預覽的用處', async (t) => {
const stub = await withStub(t, {}, { assignees: ['someone-else'], repoLabels: ['進行中'] });
const { code, json } = await run(['--dry-run'], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'CLAIMED_BY_OTHER');
});
test('--dry-run 遇到缺標籤一樣報錯,不會等到實跑才發現', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent'] });
const { json } = await run(['--dry-run'], stub);
assert.equal(json.error.code, 'LABEL_NOT_FOUND');
});
-220
View File
@@ -1,220 +0,0 @@
/**
* 委派:判準正本、正本上的標記,以及兩者之間那條雙向斷言。
*
* 判準與標記分住兩個檔案,而它們講的是同一件事。沒有雙向斷言,兩邊會慢慢漂開——
* 而漂開的時候不會有任何東西報錯:正本上多標一步不會壞,判準表少列一項也不會壞,
* 只是下一個讀的人會以為自己讀到的是全部。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DELEGATABLE,
PLATFORM_SPECIFIC,
promptSteps,
readPrompt,
readReference,
} from './helpers/prompt-doc.js';
const PROMPTS = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat', 'sdlc-fix', 'sdlc-sync', 'sdlc-report'];
const reference = readReference('delegation');
/** 帶標記的三份正本;另外三份目前沒有可委派的步驟 */
const 有標記的正本 = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat'];
/** `正本/步驟` 這種好讀的鍵,比對失敗時看得出差在哪一步 */
const 鍵 = ({ prompt, name }) => `${prompt}/${name}`;
/** delegation.md 那張表列出來的步驟 */
const 表上的 = () =>
[...reference.matchAll(/^\| `(sdlc-[a-z]+)` \| (.+?) \| (.+?) \|$/gm)].map((m) => ({
prompt: m[1],
name: m[2].trim(),
範圍: m[3].trim(),
}));
/** 正本上實際被標記的步驟 */
const 正本上的 = () =>
PROMPTS.flatMap((prompt) =>
promptSteps(readPrompt(prompt))
.filter((step) => step.marked)
.map((step) => ({ prompt, name: step.name, body: step.body })),
);
// ── 判準正本 ───────────────────────────────────────────────────────
test('delegation.md 的開頭形狀與既有規則正本一致', () => {
assert.equal(reference.startsWith('# '), true, '規則正本一律以 H1 起頭,不放 frontmatter');
assert.match(reference.split('\n')[0], /委派/);
});
/** 判準那一節的四條,各自含標題與理由 */
function 判準逐條() {
const 節 = reference.slice(reference.indexOf('## 判準'), reference.indexOf('## 怎麼委派'));
return 節.split(/^(?=\d+\. \*\*)/m).filter((one) => /^\d+\. \*\*/.test(one));
}
test('四條判準逐條載明,一條不多一條不少', () => {
const 判準 = reference.slice(reference.indexOf('## 判準'), reference.indexOf('## 怎麼委派'));
const 條 = 判準逐條().map((one) => /^\d+\. \*\*(.+?)\*\*/.exec(one)[1]);
assert.equal(條.length, 4, `判準應為四條,目前 ${條.length} 條:${條.join('、')}`);
assert.match(判準, /可驗證的成品/);
assert.match(判準, /不會詢問使用者/);
assert.match(判準, /失敗能被呼叫端偵測/);
assert.match(判準, /不直接寫入 Gitea 或 git/);
});
test('第二條與第四條各自標明是硬排除,並各自寫出理由', () => {
// 數「硬排除」出現幾次的話,別處多提一句就會失敗;要問的是「那兩條上面有沒有」
const [, 二, , 四] = 判準逐條();
assert.match(二, /硬排除/, '第二條是硬排除,不是建議');
assert.match(二, /子代理問不到使用者/, '沒寫理由的話,下一個人會把它當成建議而繞過去');
assert.match(四, /硬排除/, '第四條是硬排除,不是建議');
assert.match(四, /失敗沒有人看著/, '理由不是子代理做不好,要寫清楚,否則會被當成不信任');
});
// ── 雙向斷言 ───────────────────────────────────────────────────────
test('正本上被標記的集合,等於 delegation.md 列出的集合', () => {
const 表 = 表上的().map(鍵).sort();
const 正本 = 正本上的().map(鍵).sort();
assert.deepEqual(正本, 表, '改一邊就要改另一邊,否則兩份說法會漂開');
});
// 這一條與上一條刻意重複:雙向斷言只保證兩邊一致,兩邊一起改就一起漂走。
// 把議題點名的那六個逐字釘在這裡,改動才需要有人明確地改掉這份清單。
test('被標記的正好是議題點名的那六個', () => {
assert.deepEqual(正本上的().map(鍵).sort(), [
'sdlc-analyze/算出截止日',
'sdlc-analyze/對四份清單列出疑點',
'sdlc-analyze/產生分析版的圖解總覽',
'sdlc-feat/把議題標題翻成英文',
'sdlc-feat/分批提交',
'sdlc-plan/產生圖解版總覽',
].sort());
});
// ── 判準第二條的迴歸保護 ───────────────────────────────────────────
/** 會問使用者的步驟。子代理問不到人,這些永遠不該被標上可委派。 */
const 會問使用者 = [
['sdlc-plan', '逐項詢問'],
['sdlc-analyze', '逐題問到共識'],
['sdlc-feat', '問來源分支'],
['sdlc-feat', '認出語言,讀規則正本'],
];
test('點名的問到共識類步驟一律未被標記', () => {
for (const [prompt, name] of 會問使用者) {
const step = promptSteps(readPrompt(prompt)).find((one) => one.name === name);
assert.ok(step, `${prompt} 少了「${name}」這一步;步驟改名的話這份清單要跟著改`);
assert.equal(step.marked, false, `${prompt}/${name} 會問使用者,判準第二條硬排除`);
}
});
test('任何看得出在問使用者的步驟都沒有被標記', () => {
// 只認明確的提問語,不認「不要拿去問使用者」那種否定句——那句正好出現在可委派的步驟裡
const 提問語 = /一次問一題|停下來問|等使用者回答|問到共識|問過使用者/;
let 掃過 = 0;
let 認出 = 0;
for (const prompt of PROMPTS) {
for (const step of promptSteps(readPrompt(prompt))) {
掃過 += 1;
if (!提問語.test(step.body)) continue;
認出 += 1;
assert.equal(step.marked, false, `${prompt}/${step.name} 在問使用者,不該標可委派`);
}
}
// 兩道自我檢查:這種掃描最常見的壞法是「一條都沒掃到」,而那時它照樣是綠的。
// 目前有編號步驟的是 plan/analyze/feat/report 四份,sdlc-fix 與 sdlc-sync 沒有編號步驟
assert.ok(掃過 >= 40, `只掃到 ${掃過} 個步驟,正本的步驟標題格式可能變了`);
assert.ok(認出 >= 4, `提問語一個步驟都沒認出來(${認出}),這道保護已經形同虛設`);
});
// ── 判準第四條:不直接寫入 ─────────────────────────────────────────
/** 會寫入 Gitea 或 git 的腳本。被標記的步驟碰到它們,就要寫明哪一半不委派。 */
const 寫入型 = [
'issue-create',
'issue-update',
'issue-link',
'project-add',
'pr-create',
'claim.js',
'branch-prep',
'worktree-ensure',
'worktree-remove',
'timer.js',
'time-log.js',
'commit-split',
];
test('被標記的步驟若碰得到寫入,就要寫明哪一半不委派', () => {
for (const step of 正本上的()) {
const 碰到 = 寫入型.filter((script) => step.body.includes(script));
if (碰到.length === 0) continue;
assert.match(
step.body,
/不委派/,
`${鍵(step)} 用到 ${碰到.join('、')},要寫明那一半留給主流程`,
);
}
});
test('三步部分委派的範圍,判準表上也說得出來', () => {
const 部分 = 表上的().filter((one) => one.範圍 !== '全步');
assert.equal(部分.length, 3, '兩份圖解總覽與分批提交是部分委派');
for (const one of 部分) {
assert.match(one.範圍, /不委派/, `${鍵(one)} 的範圍要說出哪一半不委派`);
}
});
test('另外三份正本一個標記都沒有,與判準正本結尾那句話一致', () => {
for (const prompt of PROMPTS.filter((one) => !有標記的正本.includes(one))) {
assert.equal(
readPrompt(prompt).includes(DELEGATABLE),
false,
`${prompt} 出現了標記,但 delegation.md 結尾說它沒有可委派的步驟`,
);
}
assert.match(reference, /`sdlc-sync`、`sdlc-fix` 與 `sdlc-report` 目前沒有可委派的步驟/);
});
// ── 平台中立 ───────────────────────────────────────────────────────
test('判準正本與標記都不指名任何平台的工具', () => {
for (const token of PLATFORM_SPECIFIC) {
assert.equal(reference.includes(token), false, `delegation.md 不該出現平台專屬字樣:${token}`);
}
// 子代理是平台專屬能力,正本只能以能力描述帶過
assert.match(reference, /能力描述/);
assert.match(reference, /不能就自己做/);
});
test('三份帶標記的正本各自說明了這個後綴是什麼意思,並指名判準正本', () => {
for (const prompt of 有標記的正本) {
const text = readPrompt(prompt);
assert.match(text, new RegExp(`## ${DELEGATABLE}的意思`), `${prompt} 要解釋這個後綴`);
assert.match(text, /references\/delegation\.md/, `${prompt} 要指名判準正本,不要把判準抄過去`);
assert.match(text, /不能就自己做/, `${prompt} 要寫成能力描述,讓不支援的平台自然降級`);
}
});
test('標記是標題後綴,不用 emoji 也不用 HTML 註解', () => {
for (const prompt of PROMPTS) {
const text = readPrompt(prompt);
assert.equal(text.includes('<!--'), false, `${prompt}:HTML 註解模型讀不穩,不拿它當標記`);
// 標記只出現在標題後綴與那一節的說明裡,不會單獨浮在內文中間
for (const line of text.split('\n')) {
if (!line.includes(DELEGATABLE)) continue;
// 三種合法位置,寫死成互斥的三條。曾經第二條寫成 /^#{2,3} /,把第一條整個
// 吃掉了——那時候「### 工作包議題 〔可委派〕」這種沒編號的標題也會通過
const 合法 =
new RegExp(`^### \\d+\\. .+ ${DELEGATABLE}$`).test(line) ||
line === `## ${DELEGATABLE}的意思` ||
line.includes(`\`${DELEGATABLE}\``);
assert.ok(合法, `${prompt}:標記出現在不該出現的位置:${line}`);
}
}
});
-24
View File
@@ -303,27 +303,3 @@ test('--dry-run 遇到同名議題時,如實顯示實跑會是 no-op', async (
assert.equal(json.data.existing.number, 9);
});
// ── 前置檢查仍然生效 ───────────────────────────────────────────────
test('寫入型腳本一樣跑前置檢查:時間追蹤沒開就中止', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: {
status: 200,
body: {
has_issues: true,
permissions: { admin: true, push: true, pull: true },
internal_tracker: { enable_time_tracker: false },
},
},
});
const { code, json } = await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
{ env: envFor(stub) },
);
assert.equal(code, 1);
assert.equal(json.error.code, 'TIME_TRACKER_OFF');
assert.equal(stub.requests.some((r) => r.method === 'POST'), false);
});
-52
View File
@@ -1,52 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { renderOverview, validateOverview } from '../scripts/overview-render.js';
import { repoRoot } from './helpers/run-script.js';
const base = {
schemaVersion: 1,
source: { requirement: { repo: 'owner/repo', index: 7 } },
requirement: {
title: '建立需求總覽',
summary: '把複雜需求整理成可理解的交接資料。',
goals: ['可追蹤工作包', '可離線檢視'],
nonGoals: [],
diagrams: [{ kind: 'flowchart', direction: 'LR', title: '輸入流程', nodes: [{ id: 'a', label: '輸入' }, { id: 'b', label: '輸出' }], edges: [{ from: 'a', to: 'b', label: '轉換' }] }],
formulas: [{ title: '估算', latex: 'a+b', description: '簡單公式' }],
},
workPackages: [{
title: '建立抽取契約',
type: 'implementation',
description: '提供可重複的資料抽取。',
scope: '只處理抽取。',
repos: ['owner/repo'],
depends: [],
todos: [{ text: '建立函式', done: true }],
acceptance: ['輸出固定'],
issue: 'https://gitea.example/owner/repo/issues/8',
handoff: false,
}],
overview: { generatedAt: '2026-09-18T00:00:00Z' },
};
test('renderer validates and renders self-contained HTML', () => {
const html = renderOverview(base);
assert.match(html, /<!doctype html>/i);
assert.match(html, /<svg/);
assert.match(html, /建立抽取契約/);
assert.match(html, /a\+b/);
assert.doesNotMatch(html, /mermaid/);
assert.doesNotMatch(html, /cdn\.jsdelivr/);
});
test('renderer rejects unsupported schema versions and graph kinds', () => {
assert.throws(() => validateOverview({ ...base, schemaVersion: 2 }), (error) => error.code === 'SCHEMA_UNSUPPORTED');
assert.throws(() => validateOverview({ ...base, requirement: { ...base.requirement, diagrams: [{ kind: 'unknown', nodes: [], edges: [] }] } }), (error) => error.code === 'DIAGRAM_UNSUPPORTED');
});
test('renderer script is shipped and template is intentionally removed', () => {
assert.equal(readFileSync(join(repoRoot, 'scripts', 'overview-render.js'), 'utf8').includes('overview'), true);
assert.throws(() => readFileSync(join(repoRoot, 'templates', 'overview-artifact.html'), 'utf8'));
});
-36
View File
@@ -1,36 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runScript, repoRoot, tmpRoot } from './helpers/run-script.js';
const input = join(tmpRoot, 'capture-input.json');
const html = join(tmpRoot, 'capture.html');
const svg = join(tmpRoot, 'capture.svg');
const manifest = join(tmpRoot, 'capture-manifest.json');
const document = {
schemaVersion: 1,
source: { requirement: { repo: 'owner/repo', index: 7 } },
requirement: { title: 'Capture', summary: 'Fallback', goals: [], nonGoals: [], diagrams: [{ kind: 'flowchart', nodes: [{ id: 'a', label: 'A' }], edges: [] }] },
workPackages: [],
overview: { generatedAt: '2026-09-18T00:00:00Z' },
};
import { mkdirSync, writeFileSync } from 'node:fs';
mkdirSync(tmpRoot, { recursive: true });
writeFileSync(input, `${JSON.stringify(document)}\n`);
test('renderer emits SVG fallback alongside HTML', async () => {
const result = await runScript('overview-render.js', ['--input', input, '--output', html, '--svg', svg, '--manifest', manifest]);
assert.equal(result.code, 0, JSON.stringify(result.json));
assert.equal(existsSync(svg), true);
assert.match(readFileSync(svg, 'utf8'), /^<svg/);
assert.equal(result.json.data.svg, svg);
});
test('capture reports missing backend instead of claiming success', async () => {
const result = await runScript('overview-capture.js', ['--backend', 'svg', '--svg', svg, '--output', join(tmpRoot, 'capture.png')]);
if (result.code === 0) assert.equal(result.json.data.backend, 'svg');
else assert.equal(result.json.error.code, 'BINARY_NOT_FOUND');
});
-454
View File
@@ -1,454 +0,0 @@
/**
* 開立 PR 並停錶。
*
* 三件事要驗:
* 1. **標題等同分支名**——reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。
* 2. **描述的七段都在**,而且「測試結果」不是空話。這一段是 reviewer 唯一能判斷
* 「這東西真的跑過嗎」的依據,寫「已測試通過」等於沒寫。
* 3. **PR 開完才停錶**,而且開失敗時錶不能停——工時要記在真的有做事的那段時間上。
*/
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 } from './helpers/stub-gitea.js';
/** PR 開在目標專案上 */
const REPO = 'myorg/myapp';
/** 工作包議題在另一個 repo 上——這是常態,不是特例 */
const ISSUE_REPO = 'plugins/tea-sdlc';
const HEAD = 'feat/commit-split-and-pr/main';
const INDEX = 13;
/** 一份七段俱全的描述 */
const BODY = `## 摘要
工作包做完之後,變更被整理成可讀的歷史,PR 開出來,碼錶停下。
## 需求議題
#1
## 工作包議題
#13
## 變更內容
新增 commit-split 與 pr-create 兩支腳本。
## 設計重點
分批的界線是類型,一個 commit 只裝一種。
## 解決的問題
巨大的單一 commit 等於沒有歷史。
## 影響的功能
sdlc-feat 的第三段。
## 測試結果
\`\`\`
ℹ tests 527
ℹ pass 527
ℹ fail 0
\`\`\`
`;
/** 把描述寫成檔案,回傳路徑 */
function bodyFile(name, content) {
mkdirSync(tmpRoot, { recursive: true });
const path = join(tmpRoot, `pr-body-${name}-${process.hrtime.bigint()}.md`);
writeFileSync(path, content);
return path;
}
function routes(overrides = {}) {
return healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/pulls`]: { status: 200, body: [] },
[`POST /api/v1/repos/${REPO}/pulls`]: (req) => ({
status: 201,
body: { number: 99, title: req.body.title, html_url: `https://gitea.jsc.idv.tw/${REPO}/pulls/99` },
}),
[`POST /api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 201, body: {} },
...overrides,
});
}
const withStub = (t, overrides = {}) => withStubGitea(t, routes(overrides));
const BASE_ARGS = ['--repo', REPO, '--head', HEAD, '--base', 'master'];
const run = (args, stub) =>
runScript('pr-create.js', [...BASE_ARGS, ...args], { env: envFor(stub) });
/** 完整的一次呼叫:議題在另一個 repo 上 */
const runFull = (file, stub, extra = []) =>
run(['--body-file', file, '--issue-repo', ISSUE_REPO, '--index', String(INDEX), ...extra], stub);
const posts = (stub) =>
stub.requests.filter((r) => r.method === 'POST').map((r) => r.path);
// ── 標題與描述 ─────────────────────────────────────────────────────
test('PR 標題等同分支名', async (t) => {
const stub = await withStub(t);
const file = bodyFile('full', BODY);
const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json));
const pull = stub.requests.find((r) => r.method === 'POST' && r.path.endsWith('/pulls'));
assert.equal(pull.body.title, HEAD);
assert.equal(json.data.title, HEAD);
});
test('描述原樣送出,一個字都不改寫', async (t) => {
const stub = await withStub(t);
const file = bodyFile('verbatim', BODY);
await runFull(file, stub);
const pull = stub.requests.find((r) => r.method === 'POST' && r.path.endsWith('/pulls'));
assert.equal(pull.body.body, BODY);
});
test('--base 照給的值送出,不預設猜一個', async (t) => {
// 目標專案的開發分支可能叫 master、main 或 develop,猜錯會開到不存在的 base
const stub = await withStub(t);
const file = bodyFile('base', BODY);
await runFull(file, stub);
assert.equal(stub.requests.find((r) => r.method === 'POST').body.base, 'master');
});
test('沒給 --base 時擋下,並說明為什麼不替你猜', async (t) => {
const stub = await withStub(t);
const file = bodyFile('nobase', BODY);
const { json } = await runScript('pr-create.js', [
'--repo', REPO, '--head', HEAD, '--body-file', file,
'--issue-repo', ISSUE_REPO, '--index', String(INDEX),
], { env: envFor(stub) });
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--base/);
});
// ── 七段:少一段就擋 ───────────────────────────────────────────────
const SECTIONS = [
'摘要', '需求議題', '工作包議題', '變更內容',
'設計重點', '解決的問題', '影響的功能', '測試結果',
];
for (const missing of SECTIONS) {
test(`描述缺少「${missing}」時擋下,並指名缺的是哪一段`, async (t) => {
const stub = await withStub(t);
const body = BODY.split(/^## /m)
.filter((part) => !part.startsWith(missing))
.join('## ');
const file = bodyFile(`missing-${missing}`, body);
const { code, json } = await runFull(file, stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'MISSING_SECTION');
assert.match(json.error.message, new RegExp(missing));
assert.deepEqual(posts(stub), [], '描述不合格就不該開 PR');
});
}
test('段落順序不對時也擋下:reviewer 每次要在同一個位置找到同一件事', async (t) => {
const stub = await withStub(t);
const swapped = BODY.replace(
/## 設計重點([\s\S]*?)## 解決的問題([\s\S]*?)## 影響的功能/,
'## 解決的問題$2## 設計重點$1## 影響的功能',
);
const file = bodyFile('order', swapped);
const { json } = await runFull(file, stub);
assert.equal(json.error.code, 'SECTION_ORDER');
});
test('測試結果整段都是空話時擋下,不只看單行', async (t) => {
const stub = await withStub(t);
const body = BODY.replace(/## 測試結果[\s\S]*$/, '## 測試結果\n\n已測試通過\n無異常\n');
const file = bodyFile('multi-talk', body);
const { json } = await runFull(file, stub);
assert.equal(json.error.code, 'EMPTY_TEST_RESULT');
});
test('描述裡引用到「## 測試結果」這幾個字時,檢查的仍是真正那一段', async (t) => {
const stub = await withStub(t);
const body = BODY.replace(
'新增 commit-split 與 pr-create 兩支腳本。',
'新增兩支腳本,並要求 `## 測試結果` 這一段放實際輸出。',
);
const file = bodyFile('quoted-heading', body);
const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json));
});
// ── 測試結果不能是空話 ─────────────────────────────────────────────
const EMPTY_TALK = ['已測試通過', '測試通過', '全部通過', '測試皆已通過', '無'];
for (const talk of EMPTY_TALK) {
test(`測試結果只寫「${talk}」時擋下`, async (t) => {
const stub = await withStub(t);
const body = BODY.replace(/## 測試結果[\s\S]*$/, `## 測試結果\n\n${talk}\n`);
const file = bodyFile(`talk-${talk}`, body);
const { code, json } = await runFull(file, stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'EMPTY_TEST_RESULT');
assert.match(json.error.message, /實際跑過|手動驗證/);
assert.deepEqual(posts(stub), []);
});
}
test('測試結果是空的時候擋下', async (t) => {
const stub = await withStub(t);
const file = bodyFile('empty', BODY.replace(/## 測試結果[\s\S]*$/, '## 測試結果\n\n'));
const { json } = await runFull(file, stub);
assert.equal(json.error.code, 'EMPTY_TEST_RESULT');
});
test('沒有自動化測試時,寫得出可重現的手動驗證步驟就放行', async (t) => {
const stub = await withStub(t);
const manual = BODY.replace(
/## 測試結果[\s\S]*$/,
'## 測試結果\n\n本工作包無自動化測試,手動驗證步驟:\n\n'
+ '1. 執行 `node scripts/pr-create.js --dry-run`\n'
+ '2. 確認印出的標題等於分支名\n',
);
const file = bodyFile('manual', manual);
const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json));
});
// ── 停錶 ───────────────────────────────────────────────────────────
test('PR 開完之後才停錶,順序不能反', async (t) => {
const stub = await withStub(t);
const file = bodyFile('stop', BODY);
await runFull(file, stub);
assert.deepEqual(posts(stub), [
`/api/v1/repos/${REPO}/pulls`,
`/api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`,
]);
});
test('錶停在議題所在的 repo,不是 PR 所在的 repo', async (t) => {
// claim 在工作包議題上起錶,而 PR 開在目標專案上——兩者常常不是同一個 repo。
// 拿 PR 的 repo 去停錶,停到的是別人的議題,而自己的錶還在跑。
const stub = await withStub(t);
const file = bodyFile('two-repos', BODY);
const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json));
assert.equal(json.data.碼錶已停, true);
assert.equal(
posts(stub).some((path) => path.startsWith(`/api/v1/repos/${REPO}/issues/`)),
false,
'不該對 PR 的那個 repo 發停錶請求',
);
});
test('沒給 --issue-repo 時,議題就在 PR 的同一個 repo 上', async (t) => {
const stub = await withStubGitea(t, routes({
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 201, body: {} },
}));
const file = bodyFile('same-repo', BODY);
const { code } = await run(['--body-file', file, '--index', String(INDEX)], stub);
assert.equal(code, 0);
assert.ok(posts(stub).includes(`/api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`));
});
test('PR 開失敗時不停錶:工時要記在真的有做事的那段時間上', async (t) => {
const stub = await withStub(t, {
[`POST /api/v1/repos/${REPO}/pulls`]: { status: 422, body: { message: 'pull request already exists' } },
});
const file = bodyFile('fail', BODY);
const { code, json } = await runFull(file, stub);
assert.equal(code, 1);
assert.equal(
posts(stub).some((path) => path.endsWith('/stopwatch/stop')),
false,
'PR 沒開成就不該停錶',
);
assert.match(json.error.message, /422|already exists/);
});
/**
* 「沒有碼錶在跑」這件事,Gitea 不只用一種狀態碼回。
* 站台版本不同回法就不同,而這時 PR 已經建立——把整件事報成失敗,使用者會以為 PR
* 沒開成而重跑一次。
*/
const NO_STOPWATCH = [
{ status: 500, message: 'cannot stop a non existent stopwatch' },
{ status: 409, message: 'cannot stop a non-existent stopwatch' },
];
for (const { status, message } of NO_STOPWATCH) {
test(`錶本來就沒在跑時不算失敗(HTTP ${status}):PR 已經開出去了`, async (t) => {
const stub = await withStub(t, {
[`POST /api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`]: {
status,
body: { message },
},
});
const file = bodyFile(`nowatch-${status}`, BODY);
const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json));
assert.equal(json.data.碼錶已停, false);
assert.match(json.data.note ?? '', /碼錶/);
assert.ok(json.data.url, 'PR 的網址照常回報:它真的開出去了');
});
}
test('訊息對不上碼錶的 409 照常拋出,不被一起吞掉', async (t) => {
// 只有「沒有碼錶在跑」那一種能被當成不算失敗;其餘的 409 是真的有問題
const stub = await withStub(t, {
[`POST /api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`]: {
status: 409,
body: { message: 'issue is locked' },
},
});
const file = bodyFile('locked', BODY);
const { code, json } = await runFull(file, stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'HTTP_ERROR');
assert.match(json.error.message, /409/);
});
test('沒給 --index 時擋下:停錶是這一步的一部分,忘了給會讓工時算不準', async (t) => {
const stub = await withStub(t);
const file = bodyFile('noindex', BODY);
const { json } = await run(['--body-file', file], stub);
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--index/);
});
// ── 冪等:重跑不會開出第二顆 PR ───────────────────────────────────
test('同一個 head 已經有開著的 PR 時回傳既有那一顆,不再開一顆', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/pulls`]: {
status: 200,
body: [{ number: 7, title: HEAD, html_url: 'https://example.com/7', head: { ref: HEAD } }],
},
});
const file = bodyFile('dup', BODY);
const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json));
assert.equal(json.data.created, false);
assert.equal(json.data.number, 7);
assert.equal(
posts(stub).some((path) => path.endsWith('/pulls')),
false,
'既有的那一顆就是答案,不要再開一顆',
);
});
test('已經有 PR 時照樣停錶:那一步可能是上次中斷的地方', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/pulls`]: {
status: 200,
body: [{ number: 7, title: HEAD, html_url: 'https://example.com/7', head: { ref: HEAD } }],
},
});
const file = bodyFile('dup-stop', BODY);
const { json } = await runFull(file, stub);
assert.equal(json.data.碼錶已停, true);
});
test('別的分支的 PR 不算數', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/pulls`]: {
status: 200,
body: [{ number: 7, title: '別的', html_url: 'https://example.com/7', head: { ref: 'feat/別的/main' } }],
},
});
const file = bodyFile('other-branch', BODY);
const { json } = await runFull(file, stub);
assert.equal(json.data.created, true);
});
// ── 輸入 ───────────────────────────────────────────────────────────
test('描述檔不存在時回可區分的錯誤碼', async (t) => {
const stub = await withStub(t);
const { json } = await run(
['--body-file', join(tmpRoot, '不存在的檔案.md'), '--index', String(INDEX)],
stub,
);
assert.equal(json.error.code, 'FILE_NOT_FOUND');
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出將建立的 PR 與將停的錶,但不碰 Gitea', async (t) => {
const stub = await withStub(t);
const file = bodyFile('dry', BODY);
const { code, json } = await runFull(file, stub, ['--dry-run']);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.deepEqual(
json.data.requests.map((r) => `${r.method} ${r.path}`),
[
`POST /repos/${REPO}/pulls`,
`POST /repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`,
],
);
assert.equal(json.data.requests[0].body.title, HEAD, '試跑要看得到標題長什麼樣');
assert.equal(stub.requests.length, 0);
});
test('--dry-run 照樣驗描述:不合格的描述不該等到實跑才發現', async (t) => {
const stub = await withStub(t);
const file = bodyFile('dry-bad', BODY.replace('## 測試結果', '## 測試結論'));
const { json } = await runFull(file, stub, ['--dry-run']);
assert.equal(json.error.code, 'MISSING_SECTION');
});
+6 -226
View File
@@ -1,231 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript, pathWithOnly } from './helpers/run-script.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
import { runScript } from './helpers/run-script.js';
const REPO = 'plugins/tea-sdlc';
async function withStub(t, overrides = {}) {
return withStubGitea(t, healthyRoutes(REPO, overrides));
}
// ── 第一層:執行環境 ────────────────────────────────────────────────
test('第一層:PATH 上沒有 git 時中止,錯誤訊息指名 git', async (t) => {
const stub = await withStub(t);
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
env: envFor(stub),
path: pathWithOnly(['tea']),
test('一般腳本不再依賴時間追蹤設定', async () => {
const result = await runScript('report.js', ['--repo', 'plugins/tea-sdlc', '--week'], {
env: { TEA_CONFIG: '/tmp/nonexistent-tea-config' },
});
assert.equal(code, 1);
assert.equal(json.error.code, 'ENV_MISSING');
assert.match(json.error.message, /git/);
assert.equal(stub.requests.length, 0, '環境不通過就不該發請求');
});
test('第一層:PATH 上沒有 tea 時中止,錯誤訊息指名 tea 並給出安裝指引', async (t) => {
const stub = await withStub(t);
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
env: envFor(stub),
path: pathWithOnly(['git']),
});
assert.equal(code, 1);
assert.equal(json.error.code, 'ENV_MISSING');
assert.match(json.error.message, /tea/);
});
// ── 第二層:Gitea 登入 ─────────────────────────────────────────────
test('第二層:token 無效時中止,訊息指向 tea login', async (t) => {
const stub = await withStub(t, {
'GET /api/v1/user': { status: 401, body: { message: 'unauthorized' } },
});
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(code, 1);
assert.equal(json.error.code, 'LOGIN_INVALID');
assert.match(json.error.message, /tea login/);
});
test('第二層:找不到任何登入資訊時中止', async (t) => {
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
env: { TEA_SDLC_API_BASE: '', TEA_SDLC_TOKEN: '', TEA_SDLC_CONFIG: '/nonexistent/tea.yml' },
});
assert.equal(code, 1);
assert.equal(json.error.code, 'LOGIN_INVALID');
});
// ── 第三層:issues unit 寫入權 ─────────────────────────────────────
test('第三層:repo 不存在或讀不到時,錯誤碼與權限問題分得開', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: { status: 404, body: { message: 'not found' } },
});
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(code, 1);
assert.equal(json.error.code, 'REPO_NOT_FOUND');
});
test('第三層:repo 關閉議題功能時中止,訊息指出開啟位置', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: {
status: 200,
body: {
has_issues: false,
permissions: { admin: false, push: true, pull: true },
internal_tracker: { enable_time_tracker: true },
},
},
});
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(code, 1);
assert.equal(json.error.code, 'ISSUES_UNIT_OFF');
assert.match(json.error.message, /Settings/);
});
test('第三層:對 issues unit 實測寫入權,被擋下時中止', async (t) => {
const stub = await withStub(t, {
[`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: { message: 'forbidden' } },
});
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(code, 1);
assert.equal(json.error.code, 'NO_ISSUE_WRITE');
assert.match(json.error.message, /issues/i);
});
test('第三層:不能只看 permissions.push——push 為真但 issues unit 被擋,仍須失敗', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: {
status: 200,
body: {
has_issues: true,
// team 的 unit 權限可獨立於 repo 的 push 權限,所以這裡是真的也不算數
permissions: { admin: false, push: true, pull: true },
internal_tracker: { enable_time_tracker: true },
},
},
[`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: { message: 'forbidden' } },
});
const { json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(json.error.code, 'NO_ISSUE_WRITE');
});
test('第三層:探針打在不存在的議題 index 0,確保無副作用', async (t) => {
const stub = await withStub(t);
await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
const probes = stub.requests.filter((r) => r.method === 'PATCH');
assert.equal(probes.length, 1);
assert.equal(probes[0].path, `/api/v1/repos/${REPO}/issues/0`);
assert.deepEqual(probes[0].body, {}, '探針不得挾帶任何要寫入的欄位');
});
// ── 第四層:時間追蹤 ───────────────────────────────────────────────
test('第四層:時間追蹤未開啟時中止,訊息指出 Enable Time Tracker 的位置', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: {
status: 200,
body: {
has_issues: true,
permissions: { admin: false, push: true, pull: true },
internal_tracker: { enable_time_tracker: false },
},
},
});
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(code, 1);
assert.equal(json.error.code, 'TIME_TRACKER_OFF');
assert.match(json.error.message, /Settings → Advanced Settings → Enable Time Tracker/);
});
// ── 四層之間 ───────────────────────────────────────────────────────
test('四層的錯誤碼兩兩相異,呼叫端分得出是哪一層壞了', async (t) => {
const cases = [
{
code: 'ENV_MISSING',
run: async () => {
const stub = await withStub(t);
return runScript('labels-list.js', ['--repo', REPO], {
env: envFor(stub),
path: pathWithOnly(['git']),
});
},
},
{
code: 'LOGIN_INVALID',
run: async () => {
const stub = await withStub(t, {
'GET /api/v1/user': { status: 401, body: {} },
});
return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
},
},
{
code: 'NO_ISSUE_WRITE',
run: async () => {
const stub = await withStub(t, {
[`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: {} },
});
return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
},
},
{
code: 'TIME_TRACKER_OFF',
run: async () => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: {
status: 200,
body: {
has_issues: true,
permissions: { admin: true, push: true, pull: true },
internal_tracker: { enable_time_tracker: false },
},
},
});
return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
},
},
];
const seen = [];
for (const { code, run } of cases) {
const { json } = await run();
assert.equal(json.error.code, code);
seen.push(json.error.code);
}
assert.equal(new Set(seen).size, seen.length);
});
test('前一層失敗就停手,不會再往下打', async (t) => {
const stub = await withStub(t, {
'GET /api/v1/user': { status: 401, body: {} },
});
await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.deepEqual(
stub.requests.map((r) => r.path),
['/api/v1/user'],
'登入檢查沒過就不該再查 repo 或標籤',
);
assert.equal(result.code, 1);
assert.equal(result.json.error.code, 'REPORT_UNAVAILABLE');
});
+11 -508
View File
@@ -1,514 +1,17 @@
/**
* 工時報表:期間切法、週次歸屬與估算落差。
*
* 這支腳本的難處不在取資料,而在「哪一筆工時算在哪一週、哪一週算在哪個月」。
* 跨月、跨年、當月有五個週五三種邊界各自都會讓人算錯,所以逐一釘住。
*
* 時區在測試裡固定為 Asia/Taipei:週界是以人在的時區切的,不釘住時區就等於沒釘住答案。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js';
import { healthyRoutes, stubEnv, withStubGitea } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const TZ = 'Asia/Taipei';
/** 一顆工作包議題;估算寫在「關聯」段落,那是 issue-update 唯一寫得進去的地方 */
function issue(number, { title = `工作包 ${number}`, days = null, repo = REPO } = {}) {
const 關聯 = days === null ? '需求議題:#1' : `需求議題:#1\n估算人天:${days}`;
return {
number,
title,
html_url: `https://gitea.example/${repo}/issues/${number}`,
body: `## 這個工作包在做什麼\n\n做一件事\n\n## 關聯\n\n${關聯}\n`,
repository: { full_name: repo },
};
}
/** 一筆工時。created 寫成不帶時區的本地時刻,讀起來就是「那天的幾點」 */
let nextId = 1;
function time(created, hours, issueObject) {
return {
id: nextId++,
created: new Date(`${created}T10:00:00+08:00`).toISOString(),
time: Math.round(hours * 3600),
user_name: 'tester',
issue: issueObject,
};
}
/** 啟一台假 Gitea,/user/times 回傳指定的工時清單 */
async function withTimes(t, times, overrides = {}) {
return withStubGitea(
t,
healthyRoutes(REPO, {
'GET /api/v1/user/times': { status: 200, body: times },
...overrides,
}),
);
}
const run = (stub, args) =>
runScript('report.js', ['--repo', REPO, ...args], { env: { ...stubEnv(stub), TZ } });
/** 依名稱取出分段小計的秒數 */
const segmentSeconds = (json) =>
Object.fromEntries(json.data.分段.map((s) => [s.名稱, s.實際秒]));
// ── 期間:本週 ─────────────────────────────────────────────────────
test('預設為本週:起於本週一、迄於今日', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(code, 0);
assert.equal(json.data.期間.類型, 'week');
assert.equal(json.data.期間.起, '2026-09-14');
assert.equal(json.data.期間.迄, '2026-09-17');
});
test('今天就是週一時,本週只有今天這一天', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--today', '2026-09-14']);
assert.equal(json.data.期間.起, '2026-09-14');
assert.equal(json.data.期間.迄, '2026-09-14');
});
test('今天是週日時仍屬同一週,不跳到下週一', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--today', '2026-09-20']);
assert.equal(json.data.期間.起, '2026-09-14');
assert.equal(json.data.期間.迄, '2026-09-20');
});
test('只計入期間內的工時,期間外的一秒都不算', async (t) => {
const wp = issue(12);
const stub = await withTimes(t, [
time('2026-09-13', 8, wp), // 上週日
time('2026-09-14', 2, wp), // 本週一
time('2026-09-17', 1.5, wp), // 今天
time('2026-09-18', 4, wp), // 今天之後
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際秒, 3.5 * 3600);
});
test('週報沒有分段小計:一週之內沒有更小的段落', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 1, issue(12))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.deepEqual(json.data.分段, []);
});
// ── 期間:月報與 W1–W5 ────────────────────────────────────────────
test('月報依「該週週五所屬月份」歸屬:月初跨月的那一週算進本月', async (t) => {
// 2026-01 的第一個週五是 01-02,那一週的週一落在 2025-12-29
const stub = await withTimes(t, [time('2025-12-29', 3, issue(12))]);
const { json } = await run(stub, ['--month', '2026-01']);
assert.equal(json.data.期間.起, '2025-12-29');
assert.equal(json.data.總計.實際秒, 3 * 3600);
assert.equal(segmentSeconds(json).W1, 3 * 3600);
});
test('月報依「該週週五所屬月份」歸屬:月末跨月的那一週算進下個月', async (t) => {
// 2026-02-01 是週日,它那一週的週五是 01-30,所以歸 2026-01 而非 2026-02
const stub = await withTimes(t, [time('2026-02-01', 5, issue(12))]);
const january = await run(stub, ['--month', '2026-01']);
const february = await run(stub, ['--month', '2026-02']);
assert.equal(january.json.data.總計.實際秒, 5 * 3600);
assert.equal(february.json.data.總計.實際秒, 0, '同一筆工時不得被兩個月重複計算');
});
test('W 編號為該週五是當月第幾個週五,有五個週五的月份排到 W5', async (t) => {
// 2026-01 的週五:02、09、16、23、30
const wp = issue(12);
const stub = await withTimes(t, [
time('2026-01-02', 1, wp),
time('2026-01-09', 2, wp),
time('2026-01-16', 3, wp),
time('2026-01-23', 4, wp),
time('2026-01-30', 5, wp),
]);
const { json } = await run(stub, ['--month', '2026-01']);
assert.deepEqual(json.data.分段.map((s) => s.名稱), ['W1', 'W2', 'W3', 'W4', 'W5']);
assert.deepEqual(segmentSeconds(json), {
W1: 1 * 3600,
W2: 2 * 3600,
W3: 3 * 3600,
W4: 4 * 3600,
W5: 5 * 3600,
test('週報月報年報固定回傳 REPORT_UNAVAILABLE', async () => {
const result = await runScript('report.js', ['--repo', 'plugins/tea-sdlc', '--week'], {
env: { TEA_CONFIG: '/tmp/nonexistent-tea-config' },
});
assert.equal(result.code, 1);
assert.deepEqual(result.json, {
ok: false,
error: {
code: 'REPORT_UNAVAILABLE',
message: '週報、月報、年報目前不可用;時間追蹤功能已移除。',
},
});
});
test('只有四個週五的月份就只有 W1–W4,不硬湊出空的 W5', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--month', '2026-02']);
assert.deepEqual(json.data.分段.map((s) => s.名稱), ['W1', 'W2', 'W3', 'W4']);
});
test('沒有工時的週次仍然列出來,小計為零', async (t) => {
const stub = await withTimes(t, [time('2026-02-06', 1, issue(12))]);
const { json } = await run(stub, ['--month', '2026-02']);
assert.deepEqual(segmentSeconds(json), { W1: 3600, W2: 0, W3: 0, W4: 0 });
});
test('每個週次都標出自己的起迄,週一到週日', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--month', '2026-01']);
assert.deepEqual(json.data.分段[0], {
名稱: 'W1',
起: '2025-12-29',
迄: '2026-01-04',
實際秒: 0,
實際工時: '0h 00m',
});
});
// ── 期間:年報與跨年 ──────────────────────────────────────────────
test('年報以月份分段小計', async (t) => {
const stub = await withTimes(t, [time('2026-03-04', 2, issue(12))]);
const { json } = await run(stub, ['--year', '2026']);
assert.equal(json.data.分段.length, 12);
assert.deepEqual(json.data.分段.map((s) => s.名稱).slice(0, 3), ['2026-01', '2026-02', '2026-03']);
assert.equal(segmentSeconds(json)['2026-03'], 2 * 3600);
});
test('跨年的那一週依週五歸屬:12/29 的工時算進下一年', async (t) => {
// 2025-12-29 是週一,它那一週的週五是 2026-01-02
const stub = await withTimes(t, [time('2025-12-29', 6, issue(12))]);
const y2025 = await run(stub, ['--year', '2025']);
const y2026 = await run(stub, ['--year', '2026']);
assert.equal(y2025.json.data.總計.實際秒, 0);
assert.equal(y2026.json.data.總計.實際秒, 6 * 3600);
assert.equal(segmentSeconds(y2026.json)['2026-01'], 6 * 3600);
});
test('年報的起迄由第一個與最後一個週五所在的週決定', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--year', '2026']);
// 首個週五 2026-01-02 的週一是 2025-12-29;末個週五 2026-12-25 的週日是 2026-12-27
assert.equal(json.data.期間.起, '2025-12-29');
assert.equal(json.data.期間.迄, '2026-12-27');
});
// ── 估算落差 ───────────────────────────────────────────────────────
test('估算取自議題「關聯」段落的估算人天,落差為實際減估算', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 20, issue(12, { days: 2 }))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.估算人天, 2);
assert.equal(json.data.總計.落差秒, (20 - 16) * 3600, '2 人天 × 8 小時 = 16 小時');
assert.equal(json.data.總計.落差工時, '+4h 00m');
});
test('實際少於估算時落差為負', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 6, issue(12, { days: 1 }))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.落差秒, -2 * 3600);
assert.equal(json.data.總計.落差工時, '-2h 00m');
});
test('--day-hours 換掉一人天等於幾小時的假設', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 7, issue(12, { days: 1 }))]);
const { json } = await run(stub, ['--today', '2026-09-17', '--day-hours', '7']);
assert.equal(json.data.每日工時, 7);
assert.equal(json.data.總計.落差秒, 0);
});
test('議題沒寫估算時落差為 null,不當成零', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 3, issue(12))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.議題[0].估算人天, null);
assert.equal(json.data.議題[0].落差秒, null);
assert.equal(json.data.總計.估算人天, 0, '總計只加得起來有估算的那些');
assert.equal(json.data.總計.落差秒, null, '一顆估算都沒有時,沒有東西可以比');
});
test('總計的落差只拿有估算的議題來比,沒估算的工時不算成超出估算', async (t) => {
const stub = await withTimes(t, [
time('2026-09-15', 6, issue(12, { days: 1 })), // 估 8 小時、實際 6 小時
time('2026-09-16', 30, issue(13)), // 沒估算,30 小時
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際秒, 36 * 3600, '實際總計仍然是全部');
assert.equal(json.data.總計.已估實際秒, 6 * 3600, '落差的分母只有有估算的那顆');
assert.equal(json.data.總計.落差秒, -2 * 3600);
assert.notEqual(json.data.總計.落差秒, 28 * 3600, '拿全部實際去比部分估算會灌出假的超支');
});
// ── 逐議題明細 ─────────────────────────────────────────────────────
test('依議題彙總,帶上標題與網址,工時多的排前面', async (t) => {
const stub = await withTimes(t, [
time('2026-09-14', 1, issue(12, { title: '建立抽取契約', days: 3 })),
time('2026-09-15', 4, issue(13, { title: '補上前置檢查' })),
time('2026-09-16', 2, issue(12, { title: '建立抽取契約', days: 3 })),
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.deepEqual(json.data.議題, [
{
index: 13,
title: '補上前置檢查',
url: 'https://gitea.example/plugins/tea-sdlc/issues/13',
實際秒: 4 * 3600,
實際工時: '4h 00m',
估算人天: null,
落差秒: null,
落差工時: null,
},
{
index: 12,
title: '建立抽取契約',
url: 'https://gitea.example/plugins/tea-sdlc/issues/12',
實際秒: 3 * 3600,
實際工時: '3h 00m',
估算人天: 3,
落差秒: -21 * 3600,
落差工時: '-21h 00m',
},
]);
});
test('工時以時分呈現,秒數不進位成假的精確', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 1.51, issue(12))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際工時, '1h 30m');
});
// ── 範圍:只算指定 repo 的工時 ────────────────────────────────────
test('別的 repo 的工時不算進來', async (t) => {
const stub = await withTimes(t, [
time('2026-09-15', 2, issue(12)),
time('2026-09-15', 8, issue(4, { repo: 'plugins/other' })),
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際秒, 2 * 3600);
assert.deepEqual(json.data.議題.map((i) => i.index), [12]);
});
test('查不到議題資訊的工時不默默消失,回報則數', async (t) => {
const stub = await withTimes(t, [
time('2026-09-15', 2, issue(12)),
{ id: 99, created: '2026-09-15T02:00:00Z', time: 3600, user_name: 'tester' },
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.略過, 1, '無議題資訊時整份報表會憑空變空,數字要留在輸出裡');
assert.equal(json.data.總計.實際秒, 2 * 3600);
});
test('沒有任何工時時回空報表,不是錯誤', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.equal(json.data.總計.實際秒, 0);
assert.deepEqual(json.data.議題, []);
});
// ── 取資料的方式 ───────────────────────────────────────────────────
test('工時逐頁讀完,不是只讀第一頁', async (t) => {
const wp = issue(12);
const first = Array.from({ length: 50 }, () => time('2026-09-15', 0.1, wp));
const second = [time('2026-09-16', 1, wp)];
const stub = await withStubGitea(
t,
healthyRoutes(REPO, {
'GET /api/v1/user/times': (req) => ({
status: 200,
body: req.query.page === '1' ? first : second,
}),
}),
);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際秒, Math.round((50 * 0.1 + 1) * 3600));
});
test('工時內嵌的議題沒帶 body 時,補查議題才讀得到估算', async (t) => {
// /user/times 內嵌的議題不保證帶 body;少了它,估算會整欄靜靜變成 null
const bodyless = { ...issue(12, { days: 2 }) };
delete bodyless.body;
const stub = await withTimes(
t,
[time('2026-09-15', 20, bodyless), time('2026-09-16', 1, bodyless)],
{ [`GET /api/v1/repos/${REPO}/issues/12`]: { status: 200, body: issue(12, { days: 2 }) } },
);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.議題[0].估算人天, 2);
assert.equal(json.data.總計.落差秒, (21 - 16) * 3600);
const lookups = stub.requests.filter((r) => r.path === `/api/v1/repos/${REPO}/issues/12`);
assert.equal(lookups.length, 1, '同一顆議題只補查一次,不是每筆工時各查一次');
});
test('唯一的非 GET 是前置檢查的寫入權探針,報表本身不寫任何東西', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 1, issue(12))]);
await run(stub, ['--today', '2026-09-17']);
// 四層前置檢查會 PATCH 不存在的議題 0 來實測 issues 寫入權,那一筆不改動任何東西。
// 除它以外整趟都該是 GET——報表只印在終端,不對任何管道張貼。
assert.deepEqual(
stub.requests.filter((r) => r.method !== 'GET').map((r) => `${r.method} ${r.path}`),
[`PATCH /api/v1/repos/${REPO}/issues/0`],
);
});
test('--dry-run 印出將發出的請求,且完全不碰 Gitea', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, ['--today', '2026-09-17', '--dry-run']);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.deepEqual(json.data.requests, [{ method: 'GET', path: '/user/times' }]);
assert.equal(stub.requests.length, 0);
});
// ── 期間參數的把關 ─────────────────────────────────────────────────
test('三種期間彼此互斥', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, ['--month', '2026-01', '--year', '2026']);
assert.equal(code, 1);
assert.equal(json.error.code, 'PERIOD_CONFLICT');
});
test('--month 需為 YYYY-MM', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--month', '2026/01']);
assert.equal(json.error.code, 'BAD_PERIOD');
assert.match(json.error.message, /--month/);
});
test('--month 的月份需在 01–12 之間', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--month', '2026-13']);
assert.equal(json.error.code, 'BAD_PERIOD');
});
test('--year 需為四位數', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--year', '26']);
assert.equal(json.error.code, 'BAD_PERIOD');
assert.match(json.error.message, /--year/);
});
test('--today 需為真實存在的日期', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--today', '2026-02-30']);
assert.equal(json.error.code, 'BAD_PERIOD');
assert.match(json.error.message, /--today/);
});
test('--day-hours 需為正數', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--day-hours', '0']);
assert.equal(json.error.code, 'BAD_DAY_HOURS');
});
test('--week 明講出來時與預設同一段期間', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 2, issue(12))]);
const explicit = await run(stub, ['--today', '2026-09-17', '--week']);
const implicit = await run(stub, ['--today', '2026-09-17']);
assert.deepEqual(explicit.json, implicit.json);
});
test('--today 搭到月報或年報時擋下,不默默忽略', async (t) => {
const stub = await withTimes(t, []);
const month = await run(stub, ['--month', '2026-01', '--today', '2026-09-17']);
const year = await run(stub, ['--year', '2026', '--today', '2026-09-17']);
assert.equal(month.json.error.code, 'PERIOD_CONFLICT');
assert.equal(year.json.error.code, 'PERIOD_CONFLICT');
});
test('期間標籤讓人一眼看出報表涵蓋什麼', async (t) => {
const stub = await withTimes(t, []);
const week = await run(stub, ['--today', '2026-09-17']);
const month = await run(stub, ['--month', '2026-01']);
const year = await run(stub, ['--year', '2026']);
assert.equal(week.json.data.期間.標籤, '2026-09-14 ~ 2026-09-17');
assert.equal(month.json.data.期間.標籤, '2026-01');
assert.equal(year.json.data.期間.標籤, '2026');
});
test('不帶 --today 時以系統日期為準,仍算得出本週', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, []);
assert.equal(code, 0);
assert.match(json.data.期間.起, /^\d{4}-\d{2}-\d{2}$/);
assert.ok(json.data.期間.起 <= json.data.期間.迄);
});
-150
View File
@@ -1,150 +0,0 @@
/**
* sdlc-analyze 的交付物:四份可行性檢查清單與流程正本。
* 這一段不寫入 Gitea,所以沒有腳本——交付的就是這些文件本身。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertNeutralPrompt, promptStep, readPrompt, readReference } from './helpers/prompt-doc.js';
const prompt = readPrompt('sdlc-analyze');
/** 四類檢查,順序即提問順序 */
const CHECKS = [
{ kind: '架構', file: 'feasibility-architecture' },
{ kind: '邏輯', file: 'feasibility-logic' },
{ kind: '資料', file: 'feasibility-data' },
{ kind: '時程', file: 'feasibility-schedule' },
];
// ── 規則正本 ───────────────────────────────────────────────────────
test('四份可行性檢查清單各自存在且有檢查項', () => {
for (const { kind, file } of CHECKS) {
const reference = readReference(file);
const items = [...reference.matchAll(/^\d+\.\s+\*\*(.+?)\*\*/gm)];
assert.ok(items.length >= 4, `${kind}清單至少要有四條檢查項,目前 ${items.length} 條`);
}
});
test('每份清單都交代了「問題怎麼問」,不只列檢查項', () => {
for (const { kind, file } of CHECKS) {
assert.match(readReference(file), /## 問題怎麼問/, `${kind}清單缺少提問指引`);
}
});
test('各清單涵蓋議題點名的重點', () => {
assert.match(readReference('feasibility-architecture'), /循環相依/);
assert.match(readReference('feasibility-logic'), /既有功能/);
assert.match(readReference('feasibility-data'), /schema/);
assert.match(readReference('feasibility-data'), /遷移/);
assert.match(readReference('feasibility-data'), /交易邊界/);
assert.match(readReference('feasibility-schedule'), /最長路徑/);
assert.match(readReference('feasibility-schedule'), /未知數最大/);
});
// ── 流程正本 ───────────────────────────────────────────────────────
test('正本平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-analyze');
});
test('正本逐一指名四份規則正本,且順序為架構→邏輯→資料→時程', () => {
const positions = CHECKS.map(({ file }) => prompt.indexOf(`references/${file}.md`));
assert.equal(positions.every((p) => p >= 0), true, '四份清單都要被正本指名讀取');
assert.deepEqual([...positions].sort((a, b) => a - b), positions, '指名順序需為架構→邏輯→資料→時程');
});
test('正本明令一次只問一題', () => {
assert.match(prompt, /一次問一題/);
assert.match(prompt, /不要一次丟出/);
});
test('正本規定每題固定兩個選項:建議(含理由)與手動輸入', () => {
assert.match(prompt, /\*\*建議\*\*/);
assert.match(prompt, /理由/);
assert.match(prompt, /\*\*手動輸入\*\*/);
assert.match(prompt, /不被選項限制/);
});
test('正本要求前一類問完才進下一類', () => {
assert.match(prompt, /前一類的問題全部清空才進入下一類/);
});
test('正本規定最後輸出共識摘要,且摘要只印不寫', () => {
assert.match(prompt, /共識摘要/);
assert.match(prompt, /只印在終端/);
assert.match(prompt, /不寫回議題/);
});
test('正本把「共識摘要之前不寫入」寫成明確邊界', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /共識摘要之前不對 Gitea 寫入任何內容/);
assert.match(boundary, /不建議題/);
assert.match(boundary, /不留留言/);
});
test('第一段在共識摘要之前不寫入任何內容,碼錶是唯一的例外', () => {
const analysis = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## 第二段'));
for (const writer of ['issue-create', 'issue-update', 'issue-link', 'project-add']) {
assert.equal(analysis.includes(writer), false, `第一段不該出現寫入型腳本 ${writer}`);
}
assert.match(analysis, /timer\.js/, '錶要起在第一段開頭,分析最耗時的正是共識之前那一段');
});
// ── 計時 ───────────────────────────────────────────────────────────
test('起錶排在第一段開頭,停錶排在最後的回報,兩者成對出現', () => {
const 起 = prompt.indexOf(promptStep(prompt, '起錶'));
assert.ok(起 > prompt.indexOf(promptStep(prompt, '讀議題')), '讀完議題確認它存在之後才起');
assert.ok(起 < prompt.indexOf('## 第二段'), '起錶要在第一段之內:分析最耗時的是共識之前那一段');
assert.match(promptStep(prompt, '停錶並回報'), /--stop/, '停錶與最後的回報寫在同一步');
});
test('起錶與停錶指的都是同一顆需求議題', () => {
const 計時段 = [...prompt.matchAll(/node scripts\/timer\.js[^\n]*/g)].map((m) => m[0]);
assert.ok(計時段.length >= 2, '起錶與停錶都要寫在正本裡');
for (const line of 計時段) {
assert.match(line, /--index <需求議題編號>/, `錶要起停在需求議題上:${line}`);
}
});
test('正本寫出這段計時涵蓋到哪,讀的人不必自己推', () => {
assert.match(prompt, /## 計時範圍/);
assert.match(prompt, /錶不跨階段跑/);
});
test('正本交代錶已經跑在同一顆上時不重起,不把累積時間切成兩段', () => {
assert.match(prompt, /切成兩段/);
});
test('正本明令不代停別顆議題上的錶', () => {
assert.match(prompt, /請他自己去停/);
assert.match(prompt, /不停別顆議題上的錶/);
});
test('邊界把碼錶從「不寫入」裡明文除外,並寫出理由', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /碼錶除外/);
assert.match(boundary, /工時/, '要說清楚碼錶記的是工時不是內容,否則下一個人會把它刪掉');
});
test('正本要求先看未處理留言數,不是 0 就提示先整併', () => {
assert.match(prompt, /未處理留言數/);
assert.match(prompt, /sdlc-sync/);
assert.match(prompt, /先停下來|先整併/);
});
test('正本指名由 issue-extract 讀議題,而不是自己讀全文', () => {
assert.match(prompt, /issue-extract/);
assert.match(prompt, /不必再讀整份議題全文/);
});
test('正本要求能自己查證的就不要拿去問使用者', () => {
assert.match(prompt, /能在程式碼裡查證的就自己去查/);
});
test('時程清單交代了「這階段還沒有工作包」該怎麼估', () => {
// 相依鏈最長路徑預設了一份拆法,而分析階段還沒有工作包可依
assert.match(readReference('feasibility-schedule'), /暫定拆法/);
assert.match(readReference('feasibility-schedule'), /還沒有工作包/);
});
-342
View File
@@ -1,342 +0,0 @@
/**
* 正本第一段「領取與開工準備」的規則。
*
* 這些檔案是文件不是程式,但它們是指令實際交付的東西:決策表寫錯,被鎖擋下來的人
* 就會得到錯的下一步;邊界寫漏,第一段就會去做後面幾段的事。靠人記不牢,用測試釘住。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
const prompt = readPrompt('sdlc-feat');
/** 各段的內容分開切,避免把別段的字樣誤認成這一段的規則 */
const phase1 = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## 第二段'));
const phase2 = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 第三段'));
const phase3 = prompt.slice(prompt.indexOf('## 第三段'), prompt.indexOf('## 邊界'));
test('輸入收得下 /sdlc-fix 交棒過來的編號,且不要求使用者重打指令', () => {
const 輸入 = prompt.slice(prompt.indexOf('## 輸入'), prompt.indexOf('## 第一段'));
assert.match(輸入, /sdlc-fix/);
assert.match(輸入, /交棒/);
assert.match(輸入, /不要因為是交棒來的就要求他再打一次指令/);
});
test('正本平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-feat');
});
test('第一段指名四支腳本,順序為先讀再領、備妥工作樹、最後起錶', () => {
const order = ['wp-extract.js', 'claim.js', 'branch-prep.js', 'timer.js'];
const positions = order.map((name) => phase1.indexOf(name));
assert.equal(positions.every((p) => p >= 0), true, '四支腳本都要被指名');
assert.deepEqual(
[...positions].sort((a, b) => a - b),
positions,
'領取之前要先讀得懂這顆在做什麼;錶則要等工作樹建好之後才起',
);
});
test('錶等工作樹建好之後才起,並說明為什麼', () => {
assert.match(phase1, /錶不在這一步起/, '領取那一步要明講錶還沒起');
assert.match(phase1, /什麼都沒做的時間/, '要說明為什麼後移:失敗的領取不該留下憑空的工時');
});
test('工作樹一律建立,建不起來就中止而不是退回原地切分支', () => {
assert.match(phase1, /一律建立,沒有例外/);
assert.match(phase1, /不要改成在原地切分支/);
assert.match(phase1, /以為自己在隔離環境裡/, '要說明靜默降級的後果');
});
test('實作要在工作樹裡做,路徑從輸出取得', () => {
assert.match(phase1, /worktree/, '工作樹路徑印在哪個欄位要講');
assert.match(phase1, /都在那棵工作樹裡做/);
});
test('工作樹是乾淨的,且機密檔案不會被複製過去', () => {
assert.match(phase1, /沒有安裝依賴/);
assert.match(phase1, /安裝指令/);
assert.match(phase1, /\.env/);
});
test('未處理留言不是 0 時要先停下來提示整併', () => {
assert.match(phase1, /未處理留言數/);
assert.match(phase1, /先停下來/);
assert.match(phase1, /sdlc-sync/);
});
test('領取鎖的四種狀態各自交代了下一步,含放行那一種', () => {
for (const code of ['CLAIMED_BY_OTHER', 'STOPWATCH_ON_THIS_ISSUE', 'STOPWATCH_ON_OTHER_ISSUE']) {
assert.match(phase1, new RegExp(code), `${code} 要出現在決策表裡`);
}
assert.match(phase1, /沒有鎖/, '第四種狀態(放行)也要在表上,否則只剩擋的那幾種');
assert.match(phase1, /不要繞過去/, '被擋下來的處置要明講,不能靠 agent 自由發揮');
});
test('缺標籤是前置條件,不混進領取鎖的四種狀態裡', () => {
const table = phase1.slice(phase1.indexOf('| 狀態'), phase1.indexOf('鎖以外還有一個前置條件'));
assert.equal(table.includes('LABEL_NOT_FOUND'), false, '它不是鎖的狀態,別讓四種變五種');
assert.match(phase1, /LABEL_NOT_FOUND/, '但仍要交代它,否則使用者不知道怎麼辦');
});
test('工作包跨多個 repo 時怎麼開分支,有交代', () => {
assert.match(phase1, /repos/);
assert.match(phase1, /有多顆時逐一確認/);
});
test('兩種擋下來的情境各自寫明處置,且都不替使用者決定', () => {
assert.match(phase1, /SOURCE_NOT_FOUND/);
assert.match(phase1, /不要自己換一個/, '來源分支是使用者的決定,不要自己改指定別支');
assert.match(phase1, /WORKTREE_PATH_TAKEN/);
assert.match(phase1, /不要自己刪/, '路徑上的東西可能還沒保存,不該由 agent 決定刪掉');
});
test('被碼錶擋下時要說明停錶不會動到工作樹', () => {
assert.match(phase1, /停錶不會動到既有的工作樹/);
assert.match(phase1, /以為停錶等於放棄那顆工作包/, '要說明不講清楚的後果');
});
test('別顆議題上的錶只由使用者自己停,並說明為什麼不代勞', () => {
assert.match(phase1, /別顆議題上的錶一律由使用者自己停/);
assert.match(phase1, /工時記錯地方/);
// 這一道自己起的那一支要自己停,否則錶會跨階段跑;兩件事不能混成一句「一律不停」
assert.match(phase1, /停掉的只有自己起的那一支/);
});
test('來源分支要問過使用者,且一次一題、附理由與手動輸入', () => {
assert.match(phase1, /一次問一題/);
assert.match(phase1, /手動輸入/);
assert.match(phase1, /不要替他決定|不要替使用者決定/);
});
test('翻譯規則釘住 kebab 與 40 字元上限,並舉出可照抄的例子', () => {
assert.match(phase1, /kebab/);
assert.match(phase1, /40/);
assert.match(phase1, /wp-extract-contract/, '要有一個真的例子,不要只說規則');
assert.match(phase1, /不要把長句截斷/);
});
test('--type 什麼時候要給、什麼時候不能給,寫清楚了', () => {
assert.match(phase1, /`--type` 只在來源是開發分支時要給/);
assert.match(phase1, /沿用來源/);
});
test('兩處「不覆蓋他人進度」的保證都有寫出來', () => {
assert.match(phase1, /起點一律是遠端的來源分支/, '根本不碰本機分支,就沒有覆蓋的可能');
assert.match(phase1, /接上去而不是蓋掉/);
});
test('三支寫入型腳本都要求先試跑', () => {
const dryRuns = phase1.match(/--dry-run/g) ?? [];
assert.ok(dryRuns.length >= 3, `三支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`);
});
test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不改任何一行程式碼/);
assert.match(boundary, /不勾待辦/);
assert.match(boundary, /不開 PR/);
assert.match(boundary, /不自行建立標籤/);
assert.match(boundary, /不在主工作區動手/);
assert.match(boundary, /不退回原地切分支/);
assert.match(boundary, /不寫任何本機狀態檔/);
assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔');
});
test('第三段收尾時告訴使用者之後怎麼查 PR,但不自己反覆跑', () => {
assert.match(phase3, /pr-watch\.js/);
assert.match(phase3, /worktree-remove\.js/, '手動清理的出口也要講,否則沒人知道它在');
assert.match(phase3, /不要自己反覆跑/);
assert.match(phase3, /suggestedAction/, '建議動作是列舉值,要讓使用者知道有這個東西可以判斷');
});
test('邊界擋住「監看報了就自己去跑 sdlc-fix」', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不自動反覆執行 `pr-watch`/);
assert.match(boundary, /只由使用者明確叫用/);
});
// ── 第二段:逐項實作 ───────────────────────────────────────────────
test('第二段明講改的是工作樹裡的檔案,不是主工作區', () => {
assert.match(phase2, /改的是工作樹裡的檔案/);
assert.match(phase2, /落到別顆工作包的分支/, '要說明在主工作區動手的後果');
});
test('第二段指名兩份規則正本,且在改檔之前就要讀', () => {
assert.match(phase2, /references\/coding-standards\.md/);
assert.match(phase2, /references\/comment-styles\.md/);
const readAt = phase2.indexOf('coding-standards.md');
const implementAt = phase2.indexOf('### 9.');
assert.ok(readAt < implementAt, '讀規則要排在動手實作之前');
});
test('認不出語言就停下來問,不自行假設', () => {
assert.match(phase2, /認不出語言就停下來問/);
assert.match(phase2, /不要猜/);
});
test('不把規範寫進目標專案的檔案', () => {
assert.match(phase2, /不寫進目標專案的任何檔案/);
});
test('規則不在正本裡複述,只指名去哪裡讀', () => {
assert.match(phase2, /這裡不複述/);
assert.match(phase2, /兩份各自演化/, '要說出複述的代價,否則下一個人還是會抄過來');
});
test('資料範例優先取自 MCP,取不到要註明未經驗證', () => {
assert.match(phase2, /優先從 MCP 取得/);
assert.match(phase2, /由邏輯推理、未經驗證/);
});
test('回報要點出哪些範例是推理來的', () => {
const report = phase2.slice(phase2.indexOf('### 12.'));
assert.match(report, /哪些資料範例是推理來的/);
});
test('--section 一定要給,並說明不給會怎樣', () => {
assert.match(phase2, /--section/);
assert.match(phase2, /一定要給/);
assert.match(phase2, /分不出要勾哪一個/);
});
test('過程不打斷:不逐項徵求同意,只印進度', () => {
assert.match(phase2, /過程不打斷/);
assert.match(phase2, /不該按二十次同意/);
assert.match(phase2, /只印進度/);
assert.match(phase2, /\[3\/12\]/, '要給一個看得出長相的進度格式,不要只說「印進度」');
});
test('真正該停下來問的情況有列舉,不是一律不問', () => {
assert.match(phase2, /真正需要停下來問的只有三種/);
assert.match(phase2, /範圍邊界/);
});
test('勾選用 issue-update --tick,且明講要用抽取契約給的 raw', () => {
assert.match(phase2, /--tick/);
assert.match(phase2, /不要自己拼那一行/);
assert.match(phase2, /raw/);
});
test('四種勾不動的錯誤各自交代了下一步', () => {
for (const code of ['RAW_NOT_FOUND', 'RAW_AMBIGUOUS', 'NOT_A_CHECKBOX', 'SECTION_NOT_FOUND']) {
assert.match(phase2, new RegExp(code), `${code} 要出現在錯誤表裡`);
}
assert.match(phase2, /重跑 `wp-extract`/);
assert.match(phase2, /不要自己改寫議題/, '議題內容是使用者的,agent 不該代為修改');
});
test('不為了勾選留留言,並說明為什麼', () => {
assert.match(phase2, /不要為了勾選在議題上留留言/);
assert.match(phase2, /洗版/);
});
test('中斷後重跑從 Gitea 的勾選狀態接續,且不看本機檔案', () => {
assert.match(phase2, /不看任何本機檔案/);
assert.match(phase2, /done` 已經是 `true`|done.*true/);
assert.match(phase2, /no-op/, '要說明重複勾選是安全的,否則會有人先查再勾');
});
test('邊界把第二段不做的事也列出來', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /第二段只實作與勾選/);
assert.match(boundary, /不提交、不開 PR、不停錶/);
assert.match(boundary, /不改與待辦無關的程式碼/);
});
// ── 第三段:提交與開立 PR ─────────────────────────────────────────
test('第三段指名兩支腳本,順序為先提交再開 PR', () => {
const order = ['commit-split.js', 'pr-create.js'];
const positions = order.map((name) => phase3.indexOf(name));
assert.equal(positions.every((p) => p >= 0), true, '兩支腳本都要被指名');
assert.deepEqual([...positions].sort((a, b) => a - b), positions);
});
test('要等待辦全部勾完才進第三段', () => {
assert.match(phase3, /全部待辦都勾完之後才進這一段/);
});
test('--type 是程式碼那一批的類型,其餘由腳本自己認', () => {
assert.match(phase3, /測試、文件與設定檔\s*\n?由腳本自己認出來|由腳本自己認出來/);
assert.match(phase3, /不必也不能指定/);
});
test('--scope 什麼時候要給寫清楚了', () => {
assert.match(phase3, /只在某一批有多個檔案時才需要/);
assert.match(phase3, /單檔那批的 scope 就是檔名/);
});
test('commit 描述要用繁體中文,並交代夾雜英文的處理', () => {
assert.match(phase3, /描述用繁體中文/);
assert.match(phase3, /保留原文/);
});
test('跨兩個功能時要分兩次跑,且指名用哪個旗標做得到', () => {
assert.match(phase3, /分兩次跑/);
assert.match(phase3, /--files/, '光說「分兩次跑」而不說怎麼分,等於沒說');
assert.match(phase3, /失去了分批的意義/);
});
test('PR 描述的八個段落都列出來,且標明順序不能換', () => {
for (const section of [
'摘要', '需求議題', '工作包議題', '變更內容',
'設計重點', '解決的問題', '影響的功能', '測試結果',
]) {
assert.match(phase3, new RegExp(`\\*\\*${section}\\*\\*`), `缺少段落說明:${section}`);
}
assert.match(phase3, /順序不能換/);
});
test('測試結果要放實際輸出,並交代沒有自動化測試時怎麼辦', () => {
assert.match(phase3, /放實際跑過的輸出/);
assert.match(phase3, /已測試通過/, '要指名這句被禁止的寫法');
assert.match(phase3, /手動\s*\n?驗證步驟|手動驗證步驟/);
assert.match(phase3, /補真的內容/);
});
test('標題由腳本設為分支名,不另外指定', () => {
assert.match(phase3, /標題由腳本設為分支名/);
assert.match(phase3, /不必也不能另外指定/);
});
test('先開 PR 再停錶,且 PR 沒開成就不停錶', () => {
assert.match(phase3, /先開 PR 再停錶/);
assert.match(phase3, /沒開成就不停錶/);
assert.match(phase3, /工時要記在真的有做事的那段時間上/);
});
test('PR 的 repo 與議題的 repo 分開講清楚', () => {
assert.match(phase3, /--issue-repo/);
assert.match(phase3, /程式碼所在的 repo/);
assert.match(phase3, /工作包議題所在的/);
assert.match(phase3, /常常不是同一個/, '要說出為什麼需要兩個旗標');
});
test('--base 要明講,不讓腳本猜', () => {
assert.match(phase3, /--base/);
assert.match(phase3, /不替你猜/);
});
test('重跑不會開出第二顆 PR,正本要說', () => {
assert.match(phase3, /重跑不會開出第二顆 PR/);
assert.match(phase3, /created/);
});
test('提交中途失敗的處置有交代,且明講不要自己回捲歷史', () => {
assert.match(phase3, /前面已經建立的那幾顆 commit/);
assert.match(phase3, /不要自己去回捲歷史/);
});
test('兩支腳本都要求先試跑', () => {
const dryRuns = phase3.match(/--dry-run/g) ?? [];
assert.ok(dryRuns.length >= 2, `兩支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`);
});
test('邊界把第三段不做的事也列出來', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /第三段不改任何一行程式碼/);
assert.match(boundary, /不把「已測試通過」這種空話/);
assert.match(boundary, /不代替使用者決定 commit 的類型與描述/);
});
-145
View File
@@ -1,145 +0,0 @@
/**
* 流程正本與輸出模板的結構驗證。
*
* 這兩份是檔案而非程式,但它們是 #4 實際交付的東西:模板段落順序決定了下游
* issue-extract 解析得到什麼,正本的平台中立性決定了轉接檔能不能一份寫到底。
* 用測試釘住,比靠人記得住可靠。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertDiagramPlaceholderOnly,
assertNeutralPrompt,
assertPromptListsSections,
assertTemplateSections,
promptStep,
readPrompt,
readTemplate,
} from './helpers/prompt-doc.js';
const template = readTemplate('requirement-issue');
const prompt = readPrompt('sdlc-plan');
/** 某一步在正本裡的位置;以名字取而不是以編號取,插一步不會讓這些測試框錯段落 */
const at = (name) => prompt.indexOf(promptStep(prompt, name));
/** 需求議題的九個段落,順序即議題裡的順序 */
const SECTIONS = [
'總覽',
'背景',
'目標',
'非目標',
'領域名詞表',
'流程圖',
'驗收標準',
'影響範圍',
'未決事項',
];
// ── 輸出模板 ───────────────────────────────────────────────────────
test('模板依序包含九個段落', () => {
assertTemplateSections(template, SECTIONS);
});
test('模板以 {{變數}} 佔位,不留任何空白待填欄位', () => {
const placeholders = [...template.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1]);
assert.ok(placeholders.length >= SECTIONS.length, '每個段落至少要有一個佔位');
for (const name of placeholders) {
assert.match(name, /^[a-z一-龥]+$/u, `佔位名稱 ${name} 應為單一詞,不含空白或符號`);
}
});
test('總覽段落預留了總覽網頁的連結佔位', () => {
const overview = template.slice(template.indexOf('## 總覽'), template.indexOf('## 背景'));
assert.match(overview, /\{\{總覽\}\}/);
assert.match(overview, /\{\{總覽網頁\}\}/);
});
// ── 流程正本 ───────────────────────────────────────────────────────
test('正本平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-plan');
});
test('正本交代了三種輸入都要能吃', () => {
for (const kind of ['自由文字', '規格檔', '議題編號']) {
assert.match(prompt, new RegExp(kind), `正本要說明輸入可為${kind}`);
}
});
test('正本明令缺漏資訊要逐項問,不得自行編造', () => {
assert.match(prompt, /一次問一題|逐項詢問/);
assert.match(prompt, /不得(自行|替使用者)?(編造|填入)/);
});
test('正本釘住抽象圖表的節點上限與字數上限', () => {
assert.match(prompt, /抽象節點與邊|抽象/);
assert.match(prompt, /12/);
assert.match(prompt, /8\s*字/);
assert.match(prompt, /拆圖|omitted/);
});
test('正本要求標籤只能從既有標籤挑,並指名用 labels-list 取得', () => {
assert.match(prompt, /labels-list/);
assert.match(prompt, /不(得|能)(自行)?建立(新)?標籤/);
});
test('正本指名由 issue-create 寫入,並提醒先以 --dry-run 檢查', () => {
assert.match(prompt, /issue-create/);
assert.match(prompt, /--dry-run/);
});
test('正本逐一交代九個段落,且順序與模板一致', () => {
// 只看「組出議題內容」那份編號清單,不看散落在行文裡的提及
assertPromptListsSections(prompt, SECTIONS);
});
test('正本交代圖表由 renderer 重新產生', () => {
assert.match(prompt, /overview-render\.js/);
assert.match(prompt, /重新產生 SVG/);
assert.match(prompt, /產生 Mermaid/);
});
// ── 計時 ───────────────────────────────────────────────────────────
test('正本寫出這段計時涵蓋到哪,讀的人不必自己推', () => {
assert.match(prompt, /## 計時範圍/);
assert.match(prompt, /錶不跨階段跑/);
});
test('議題建立之前先記下開始時間:那時候還沒有標的可起錶', () => {
assert.ok(at('記下開始時間') < at('先試跑,再寫入'), '要在議題建立之前就記下');
assert.match(promptStep(prompt, '記下開始時間'), /不要憑印象回推/);
});
test('補登排在議題建立之後、起錶之前,三者指向同一顆議題', () => {
const 補登 = prompt.indexOf('time-log.js');
const 起錶 = prompt.indexOf('timer.js');
assert.ok(at('先試跑,再寫入') < 補登, '議題還不存在時無處可補');
assert.ok(補登 < 起錶, '順序反過來的話補登會被當成做過了而跳過');
for (const line of [...prompt.matchAll(/node scripts\/(?:time-log|timer)\.js[^\n]*/g)]) {
assert.match(line[0], /--index <編號>/, `補登與起錶要指向同一顆議題:${line[0]}`);
}
});
test('補登的長度由腳本算,不要 agent 自己做減法', () => {
assert.match(prompt, /--since <記下的開始時間>/);
assert.match(prompt, /不必自己做減法/);
});
test('補登不設時間上限,也不因為時間長就改口問使用者', () => {
assert.match(prompt, /不設時間上限/);
assert.match(prompt, /不必為此多長一題出來問使用者/);
});
test('停錶排在最後的回報那一步,與起錶成對', () => {
assert.match(promptStep(prompt, '停錶並回報'), /--stop/, '停錶與回報寫在同一步');
assert.ok(at('補登規劃時間,然後起錶') < at('停錶並回報'), '先起才有得停');
});
test('正本明令不代停別顆議題上的錶', () => {
assert.match(prompt, /請他自己去停/);
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不停別顆議題上的錶/);
});
-109
View File
@@ -1,109 +0,0 @@
/**
* 工時報表的流程正本與輸出模板。
*
* 報表的難處是規則而不是程式:期間怎麼切、落差怎麼讀、印到哪裡為止。
* 規則寫在正本裡,錯了不會有任何測試自己爆掉,所以在這裡釘住。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertNeutralPrompt,
assertTemplateSections,
readPrompt,
readTemplate,
} from './helpers/prompt-doc.js';
const template = readTemplate('report');
const prompt = readPrompt('sdlc-report');
/** 報表的四個段落,順序即印出來的順序 */
const SECTIONS = ['總計', '分段小計', '逐議題', '附註'];
// ── 輸出模板 ───────────────────────────────────────────────────────
test('模板依序包含四個段落', () => {
assertTemplateSections(template, SECTIONS);
});
test('模板以 {{變數}} 佔位,不留任何空白待填欄位', () => {
const placeholders = [...template.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1]);
assert.ok(placeholders.length >= SECTIONS.length, '每個段落至少要有一個佔位');
for (const name of placeholders) {
assert.match(name, /^[a-z一-龥]+$/u, `佔位名稱 ${name} 應為單一詞,不含空白或符號`);
}
});
test('模板不含邏輯:沒有任何條件或迴圈語法', () => {
assert.equal(/\{\{[#/^]/.test(template), false, '分段與逐議題由呼叫端展開,模板不做迴圈');
});
test('總計把實際、估算與落差擺在同一列,落差不必自己算', () => {
const totals = template.slice(template.indexOf('## 總計'), template.indexOf('## 分段小計'));
for (const placeholder of ['{{實際工時}}', '{{估算人天}}', '{{已估實際}}', '{{落差}}']) {
assert.ok(totals.includes(placeholder), `總計缺少 ${placeholder}`);
}
});
// ── 流程正本 ───────────────────────────────────────────────────────
test('正本平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-report');
});
test('正本交代三種期間,並指明預設是本週', () => {
assert.match(prompt, /--week/);
assert.match(prompt, /--month YYYY-MM/);
assert.match(prompt, /--year YYYY/);
assert.match(prompt, /預設/);
});
test('正本釘住期間定義的三句話', () => {
assert.match(prompt, /一週為週一至週日/);
assert.match(prompt, /該週週五所屬月份/);
assert.match(prompt, /W1[–-]W5.*第幾個週五/s);
});
test('正本以實例說明跨月與跨年那一週落在哪邊', () => {
assert.match(prompt, /2025-12-29/, '跨年的例子要寫出具體日期,規則才驗得出來');
assert.match(prompt, /2026-02-01/, '跨月的例子要寫出具體日期');
});
test('正本指名由 report.js 取數字,並要求直接用算好的時分', () => {
assert.match(prompt, /report\.js/);
assert.match(prompt, /不要自己再乘|已經算好/);
});
test('正本明令只印在終端,不張貼到任何管道', () => {
assert.match(prompt, /只印在終端/);
assert.match(prompt, /不(要)?張貼/);
});
test('正本說明落差的正負方向,以及一人天等於幾小時', () => {
assert.match(prompt, /正數.*超出估算/);
assert.match(prompt, /8\s*小時/);
assert.match(prompt, /--day-hours/);
});
test('正本交代總計的落差只涵蓋有估算的議題', () => {
assert.match(prompt, /總計的落差只涵蓋有估算的議題/);
assert.match(prompt, /已估實際秒/, '要指名分子是哪一個欄位,否則會被讀成全部實際');
});
test('正本交代沒寫估算時落差是空的,不是零', () => {
assert.match(prompt, /不是零|非零|空的/);
assert.match(prompt, /null/);
});
test('正本交代略過的工時筆數要講出來', () => {
assert.match(prompt, /略過/);
});
test('正本的邊界寫明這是唯讀流程', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不寫入|只發 GET/);
assert.match(boundary, /不補登|不動碼錶/);
});
test('正本交代週報沒有分段小計時整段不印', () => {
assert.match(prompt, /週報沒有分段/);
});
-196
View File
@@ -1,196 +0,0 @@
/**
* 補登工時。
*
* 規劃階段最耗時的那一段發生在議題建立之前——那時候沒有標的可起錶,時間只能事後補登。
* 這一支的價值全在「補多少」:長度由腳本自己算,不由 agent 做減法。終點看議題是不是
* 這一輪建立的——是就補到建立那一刻,不是就補到現在,那一輪的規劃時間照樣要進報表。
* 唯一不補的情形是錶已經在這顆議題上跑著,那一段已經有錶在記了。
*/
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 = 42;
/** 議題建立於此刻;每支測試的 --since 都相對它往前推 */
const CREATED = '2026-09-17T10:30:00Z';
/** CREATED 往前推 n 秒的 ISO 時間 */
const 早於建立 = (seconds) => new Date(Date.parse(CREATED) - seconds * 1000).toISOString();
/** 議題上跑著的碼錶長什麼樣 */
const stopwatchOn = (index, repo = REPO) => ({
issue_index: index,
repo_owner_name: repo.split('/')[0],
repo_name: repo.split('/')[1],
});
function routes(overrides = {}, { stopwatches = [] } = {}) {
return healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: {
number: INDEX,
title: '為規劃與分析階段計時並補登規劃時間',
created_at: CREATED,
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
},
},
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/times`]: { status: 200, body: { id: 1 } },
...overrides,
});
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
const run = (since, args, stub) =>
runScript('time-log.js', ['--repo', REPO, '--index', String(INDEX), '--since', since, ...args], {
env: envFor(stub),
});
/** 會改動 Gitea 的請求;前置檢查打在 issues/0 的探針不算(見 lib 的 checkIssueWrite) */
const writes = (stub) =>
stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0'));
test('補登時長等於指令開始到議題建立的差值', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(早於建立(25 * 60), [], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.補登, true);
assert.equal(json.data.秒數, 25 * 60);
const [write] = writes(stub);
assert.equal(write.path, `/api/v1/repos/${REPO}/issues/${INDEX}/times`);
assert.deepEqual(write.body, { time: 25 * 60 });
});
test('長度由腳本自己算:議題的建立時間減掉 --since,agent 不必做減法', async (t) => {
const stub = await withStub(t);
const { json } = await run(早於建立(90), [], stub);
assert.equal(json.data.秒數, 90);
assert.equal(Date.parse(json.data.迄), Date.parse(CREATED), '終點就是議題建立那一刻');
assert.equal(json.data.依據, '議題建立');
});
test('不設時間上限:中間去開會的那幾個小時照實補登,不改口問使用者', async (t) => {
const stub = await withStub(t);
const 十小時 = 10 * 60 * 60;
const { code, json } = await run(早於建立(十小時), [], stub);
assert.equal(code, 0, '時間長不是失敗;記多了看得出來,記不到就永遠找不回來');
assert.equal(json.data.秒數, 十小時);
assert.deepEqual(writes(stub).at(0).body, { time: 十小時 });
});
test('錶已經跑在這顆議題上時跳過:補下去會與錶涵蓋的區間重疊', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { code, json } = await run(早於建立(600), [], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.補登, false);
assert.match(json.data.note, /起錶/);
assert.deepEqual(writes(stub), [], '那一段已經有錶在記,補下去就記了兩遍');
});
test('對既有議題重跑時補到現在,那一輪的規劃時間照樣進報表', async (t) => {
// 議題比 --since 還早,代表這不是這一輪建立的;拿舊的建立時間當終點會算出負數,
// 等於把這一輪的工夫丟掉
const stub = await withStub(t);
const 起 = new Date(Date.now() - 20 * 60 * 1000).toISOString();
const { code, json } = await run(起, [], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.補登, true);
assert.equal(json.data.依據, '補登當下');
assert.ok(Math.abs(json.data.秒數 - 20 * 60) <= 5, `補的應是這一輪的長度,實際 ${json.data.秒數}`);
assert.equal(writes(stub).at(0).body.time, json.data.秒數);
});
test('重跑是累計不是覆蓋:每一輪各記一筆,加總才是這顆議題真正的規劃時間', async (t) => {
const stub = await withStub(t);
const 起 = new Date(Date.now() - 10 * 60 * 1000).toISOString();
const 第一輪 = await run(起, [], stub);
const 第二輪 = await run(起, [], stub);
assert.equal(第一輪.json.data.補登, true);
assert.equal(第二輪.json.data.補登, true, '前一輪記過了不是跳過的理由');
assert.equal(writes(stub).length, 2, '兩輪各記一筆');
});
test('錶跑在別顆議題上不影響補登:補登只寫工時,不動任何錶', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
const { code, json } = await run(早於建立(600), [], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.補登, true);
assert.deepEqual(
writes(stub).map((r) => r.path),
[`/api/v1/repos/${REPO}/issues/${INDEX}/times`],
'不得順手停掉別顆議題上的錶',
);
});
test('指令開始時間落在未來時什麼都不補,也不算失敗', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(new Date(Date.now() + 60 * 1000).toISOString(), [], stub);
assert.equal(code, 0, '兩邊時鐘差幾秒是常事,不該讓整個流程停在這裡');
assert.equal(json.data.補登, false);
assert.deepEqual(writes(stub), []);
});
test('--since 不是可解析的時間時擋在打 Gitea 之前', async (t) => {
const stub = await withStub(t);
const { code, json } = await run('剛剛', [], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'BAD_SINCE');
assert.equal(stub.requests.length, 0);
});
test('議題不存在時回可區分的錯誤碼', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
});
const { json } = await run(早於建立(600), [], stub);
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
assert.deepEqual(writes(stub), []);
});
test('--dry-run 印出將發出的補登,但一個字都不寫進去', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(早於建立(1800), ['--dry-run'], stub);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.equal(json.data.秒數, 1800);
assert.deepEqual(json.data.requests, [
{ method: 'POST', path: `/repos/${REPO}/issues/${INDEX}/times`, body: { time: 1800 } },
]);
assert.deepEqual(writes(stub), [], '預覽不得真的寫入');
});
test('--dry-run 會先讀現況:錶已經在跑時預覽出來就是什麼都不做', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { json } = await run(早於建立(1800), ['--dry-run'], stub);
assert.deepEqual(json.data.requests, [], '手寫一份固定的清單會跟實作走鐘');
assert.equal(json.data.補登, false);
});
-244
View File
@@ -1,244 +0,0 @@
/**
* 起錶與停錶。
*
* 錶是工時報表的唯一來源,所以起錶那一半的價值全在「什麼時候不該起」:工作樹還沒建好
* 不該起(那由流程的順序保證),自己的錶已經跑在別顆議題上更不該起——那會把兩顆
* 工作包的時間攪在一起。
*
* `--stop` 只停 `--index` 指的那一顆。每個階段停掉自己起的那支錶,錶就不會跨階段跑;
* 但別顆議題上的錶一律不碰——那一段時間該記在哪顆議題上只有人知道,而靜默替人結算
* 正是領取鎖那條規則當初要擋的事。
*/
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 = 40;
/** 議題上跑著的碼錶長什麼樣 */
const stopwatchOn = (index, repo = REPO) => ({
issue_index: index,
repo_owner_name: repo.split('/')[0],
repo_name: repo.split('/')[1],
});
function routes(overrides = {}, { stopwatches = [] } = {}) {
return healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: {
number: INDEX,
title: '以 worktree 建立工作包分支並備妥隔離環境',
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
},
},
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`]: { status: 201, body: {} },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 200, body: {} },
...overrides,
});
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
const run = (args, stub) =>
runScript('timer.js', ['--repo', REPO, '--index', String(INDEX), ...args], { env: envFor(stub) });
/** 會改動 Gitea 的請求;前置檢查打在 issues/0 的探針不算(見 lib 的 checkIssueWrite) */
const writes = (stub) =>
stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0'));
test('沒有錶在跑時起錶,並回報起在哪一顆上', async (t) => {
const stub = await withStub(t);
const { code, json } = await run([], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.碼錶中, true);
assert.equal(json.data.index, INDEX);
assert.deepEqual(
writes(stub).map((r) => `${r.method} ${r.path}`),
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`],
);
});
test('錶已經跑在這顆議題上時什麼都不做,重跑不會把計時打斷', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { code, json } = await run([], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.碼錶中, true);
assert.equal(json.data.已在計時, true, '要如實說這顆本來就在計時,不要假裝是這次起的');
assert.deepEqual(writes(stub), [], '重新起錶會把已經累積的時間切成兩段');
});
test('錶跑在別顆議題上時擋下,並指出是哪一顆', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
assert.match(json.error.message, /#7/, '忘了停掉的是哪一顆,要指名');
assert.deepEqual(writes(stub), [], '擋下來就不該寫進任何東西');
});
test('別的 repo 上的同號碼錶也算自己有錶在跑', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX, 'plugins/別的專案')] });
const { json } = await run([], stub);
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
assert.match(json.error.message, /別的專案/);
});
test('不代替使用者停錶:訊息要說清楚下一步是他自己去停', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
const { json } = await run([], stub);
assert.match(json.error.message, /停/);
assert.match(
json.error.message,
/不會動到任何既有的工作樹/,
'碼錶只管時間、工作樹只管檔案;不講清楚,使用者會以為停錶等於放棄那顆工作包',
);
});
test('議題不存在時回可區分的錯誤碼', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
});
const { json } = await run([], stub);
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
assert.deepEqual(writes(stub), []);
});
test('--index 不是正整數時擋在打 Gitea 之前', async (t) => {
const stub = await withStub(t);
const { json } = await runScript('timer.js', ['--repo', REPO, '--index', '0'], {
env: envFor(stub),
});
assert.equal(json.error.code, 'BAD_INDEX');
assert.equal(stub.requests.length, 0);
});
test('--dry-run 印出將發出的寫入,但一個字都不寫進去', 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}`),
[`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`],
);
assert.deepEqual(writes(stub), [], '預覽不得真的寫入');
});
test('--dry-run 會先讀現況:已經在計時時預覽出來就是什麼都不做', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { json } = await run(['--dry-run'], stub);
assert.deepEqual(json.data.requests, [], '手寫一份固定的清單會跟實作走鐘');
assert.equal(json.data.已在計時, true);
});
// ── 停錶 ───────────────────────────────────────────────────────────
test('--stop 停掉跑在這顆議題上的錶', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { code, json } = await run(['--stop'], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.碼錶已停, true);
assert.deepEqual(
writes(stub).map((r) => `${r.method} ${r.path}`),
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`],
);
});
test('--stop 在錶本來就沒在跑時不算失敗,並說明這一步略過了', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(['--stop'], stub);
assert.equal(code, 0, '這一步多半排在回報之前,報成失敗會讓人以為前面那件事沒做成');
assert.equal(json.data.碼錶已停, false);
assert.match(json.data.note, /沒在/);
assert.deepEqual(writes(stub), []);
});
test('--stop 不碰別顆議題上的錶:錶在別顆時什麼都不停', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
const { code, json } = await run(['--stop'], stub);
assert.equal(code, 0);
assert.equal(json.data.碼錶已停, false);
assert.deepEqual(writes(stub), [], '靜默替人結算別顆議題,正是領取鎖那條規則要擋的事');
});
test('--stop 的 --dry-run 印出將發出的停錶,且不真的停', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { code, json } = await run(['--stop', '--dry-run'], stub);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.deepEqual(
json.data.requests.map((r) => `${r.method} ${r.path}`),
[`POST /repos/${REPO}/issues/${INDEX}/stopwatch/stop`],
);
assert.deepEqual(writes(stub), []);
});
test('--stop 認得站台把「沒有錶在跑」回成 409 或 500 的兩種寫法', async (t) => {
for (const status of [409, 500]) {
const stub = await withStubGitea(
t,
routes(
{
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: {
status,
body: { message: 'cannot stop non existent stopwatch' },
},
},
{ stopwatches: [stopwatchOn(INDEX)] },
),
);
const { code, json } = await run(['--stop'], stub);
assert.equal(code, 0, `${status} 若說的是碼錶,就不是真的伺服器錯誤`);
assert.equal(json.data.碼錶已停, false);
}
});
test('--stop 遇到真的伺服器錯誤時照樣失敗,不吞掉', async (t) => {
const stub = await withStub(
t,
{
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: {
status: 500,
body: { message: 'database is on fire' },
},
},
{ stopwatches: [stopwatchOn(INDEX)] },
);
const { code, json } = await run(['--stop'], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'HTTP_ERROR');
});
-135
View File
@@ -1,135 +0,0 @@
/**
* 工作包議題的模板,以及正本裡「產生工作包」那一段的規則。
*
* 模板的段落順序決定 #9 的 wp-extract 解析得到什麼;待辦的巢狀寫法決定實作階段
* 勾得到哪一行。這兩件事寫死在測試裡,改動時才會被逼著一起改。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertDiagramPlaceholderOnly,
assertPromptListsSections,
assertTemplateSections,
readPrompt,
readTemplate,
} from './helpers/prompt-doc.js';
const template = readTemplate('work-package-issue');
const prompt = readPrompt('sdlc-analyze');
/** 第二段的內容,避免把第一段的字樣誤認成這一段的規則 */
const phase2 = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 第三段'));
/** 工作包議題的九個段落,順序即議題裡的順序 */
const SECTIONS = [
'這個工作包在做什麼',
'描述',
'架構圖',
'範圍邊界',
'介面契約',
'待辦',
'整體驗收',
'repo 列表',
'關聯',
];
// ── 輸出模板 ───────────────────────────────────────────────────────
test('模板依序包含九個段落', () => {
assertTemplateSections(template, SECTIONS);
});
test('模板每個段落都有 {{變數}} 佔位', () => {
const placeholders = [...template.matchAll(/\{\{([^}]+)\}\}/g)];
assert.equal(placeholders.length, SECTIONS.length);
});
test('介面契約是四欄表格:介面/產出者/消費者/形狀', () => {
const section = template.slice(template.indexOf('## 介面契約'), template.indexOf('## 待辦'));
assert.match(section, /\|\s*介面\s*\|\s*產出者\s*\|\s*消費者\s*\|\s*形狀\s*\|/);
assert.match(section, /\|\s*---\s*\|/, '要有分隔列,wp-extract 以它為界找資料列');
});
test('模板不把 mermaid 圍欄寫死:不畫圖時才不會留下渲染失敗的空區塊', () => {
assertDiagramPlaceholderOnly(template, '架構圖', '範圍邊界');
});
// ── 產生工作包那一段 ───────────────────────────────────────────────
test('第二段要等使用者對共識摘要點頭才開始', () => {
assert.match(phase2, /點頭之後才開始/);
assert.match(phase2, /沒有經過確認就不要往下走/);
});
test('正本逐一交代九個段落,且順序與模板一致', () => {
assertPromptListsSections(phase2, SECTIONS);
});
test('標題規則為動詞加名詞,且明令禁止流水編號', () => {
assert.match(phase2, /\{動詞\}\{名詞\}/);
assert.match(phase2, /禁止流水編號/);
assert.match(phase2, /WP-01/, '要舉出被禁止的寫法,不要只說「不要用編號」');
});
test('待辦的巢狀寫法有具體範例,且說明上層與縮排各代表什麼', () => {
const example = phase2.match(/```[^\n]*\n([\s\S]*?)```/)?.[1] ?? '';
const indents = example
.split('\n')
.filter((line) => line.trim().startsWith('- ['))
.map((line) => line.match(/^\s*/)[0].length);
assert.ok(indents.length >= 3, '範例要有數行待辦才看得出結構');
assert.ok(
Math.max(...indents) > Math.min(...indents),
'範例要真的有縮排出來的巢狀層,不能只用文字描述',
);
assert.match(phase2, /上層是待辦、縮排一層是該項的驗收/);
assert.match(phase2, /不要再往下巢狀/);
});
test('範圍邊界要求明列不做什麼', () => {
assert.match(phase2, /明列\*\*不做什麼\*\*/);
assert.match(phase2, /抵抗範圍蔓延/);
});
test('介面契約段落交代了沒有對外介面時怎麼填', () => {
assert.match(phase2, /不要留空表/);
});
test('關聯段落必須指回來源需求議題', () => {
assert.match(phase2, /需求議題:#/);
});
test('架構圖使用抽象資料並釘住節點與字數上限', () => {
assert.match(prompt, /抽象.*kind|抽象.*nodes/);
assert.match(prompt, /12/);
assert.match(prompt, /8\s*字/);
assert.match(prompt, /拆圖|omitted/);
});
test('寫入前先試跑,且試跑的價值有被說明', () => {
assert.match(phase2, /--dry-run/);
assert.match(phase2, /no-op/, '要說明試跑會顯示「實跑是 no-op」,否則使用者不知道該看什麼');
});
test('標籤只能從既有標籤挑,且指名用 labels-list 取得', () => {
assert.match(phase2, /labels-list/);
assert.match(phase2, /不得自行建立新標籤/);
});
test('冪等查重有被交代:重跑不會產生第二顆', () => {
assert.match(phase2, /重跑不會產生重複工作包/);
assert.match(phase2, /created.*false|`created` 設為 `false`/);
});
test('第二段明列它「不做」的事,避免搶走後續流程的工作', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不建相依/);
assert.match(boundary, /不掛 Milestone/);
assert.match(boundary, /不加看板/);
assert.match(boundary, /不寫人天估算/);
});
test('缺少圖表資料時必須留下可追蹤原因', () => {
const limits = prompt.slice(prompt.indexOf('## 架構圖的限制'));
assert.match(limits, /拆圖|omitted/);
});
-654
View File
@@ -1,654 +0,0 @@
/**
* 工作包議題的抽取契約。
*
* 與需求議題那一支(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, user: { login: c.reactedBy ?? 'tester' } })),
};
});
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);
});
-68
View File
@@ -1,68 +0,0 @@
/**
* 正本第三段「排上時程與看板」的規則。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
const prompt = readPrompt('sdlc-analyze');
const phase3 = prompt.slice(prompt.indexOf('## 第三段'), prompt.indexOf('## 架構圖的限制'));
test('正本仍然平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-analyze');
});
test('第三段指名四支腳本,順序為先算再寫', () => {
const order = ['schedule.js', 'issue-link.js', 'issue-update.js', 'project-add.js'];
const positions = order.map((name) => phase3.indexOf(name));
assert.equal(positions.every((p) => p >= 0), true, '四支腳本都要被指名');
assert.deepEqual([...positions].sort((a, b) => a - b), positions, '要先算出截止日才寫得下去');
});
test('計畫檔的格式有可照抄的範例', () => {
assert.match(phase3, /"startDate"/);
assert.match(phase3, /"workPackages"/);
assert.match(phase3, /"depends"/);
});
test('交代了拓撲排序保證什麼,以及成環時怎麼辦', () => {
assert.match(phase3, /截止日都不早於它的先決/);
assert.match(phase3, /成環/);
assert.match(phase3, /回頭改拆法/);
});
test('說明日期只算日曆日,不替使用者決定跳哪些日子', () => {
assert.match(phase3, /日曆日/);
assert.match(phase3, /不跳週末/);
});
test('三支寫入腳本都要求先試跑,並點出它們是冪等的', () => {
assert.match(phase3, /--dry-run/);
assert.match(phase3, /冪等/);
});
test('Milestone 與看板都只掛既有的,且交代反查不到時怎麼辦', () => {
assert.match(phase3, /只掛既有的/);
assert.match(phase3, /不建立 Milestone/);
assert.match(phase3, /不建立專案/);
assert.match(phase3, /貼專案網址/);
});
test('回報時要指出相依鏈最長路徑', () => {
assert.match(phase3, /相依鏈最長路徑/);
});
test('人天估算的 API 限制寫成獨立一節,不是藏在行文裡', () => {
const limit = prompt.slice(prompt.indexOf('## 已知限制'), prompt.indexOf('## 架構圖的限制'));
assert.match(limit, /time_estimate/);
assert.match(limit, /無法由 API 寫入/);
assert.match(limit, /估算人天:/, '要說清楚改寫到哪裡去');
assert.match(limit, /sdlc-report/, '要說清楚誰會讀這一行');
});
test('邊界把三段各自不做的事分開列', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /共識摘要之前不對 Gitea 寫入任何內容/);
assert.match(boundary, /第二段只建立工作包議題/);
assert.match(boundary, /第三段只掛既有的 Milestone 與看板/);
});