test(逐項實作): 覆蓋勾選的五種危險、兩份規則正本與正本第二段
勾選的測試全部繞著「會不會改錯行」打轉,五種都是 code review 抓出來的實際缺陷: 圍欄裡長得像 checkbox 的那一行不會被改到、不同段落的同一句話靠 --section 分得開、 大寫 [X] 重跑是 no-op、方框後沒有空白照樣勾得到、沒有方框的項目給的是指路的錯誤 而不是謊報已勾過。 另外釘住抽取端與勾選端的一致性:wp-extract 交得出來的每一種 raw,--tick 都要收得下。 patchOf 收進 helpers——它先前在三個測試檔裡各有一份一模一樣的定義。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 實作規範與註解格式對照表這兩份規則正本。
|
||||
*
|
||||
* 它們是 /sdlc-feat 第二段實際交付的東西:規範寫漏一條,產出的程式碼就少一種註解,
|
||||
* 而那要等 reviewer 看到才會發現。對照表少一種語言,agent 就會開始猜格式。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readReference } from './helpers/prompt-doc.js';
|
||||
|
||||
const standards = readReference('coding-standards');
|
||||
const styles = readReference('comment-styles');
|
||||
|
||||
/**
|
||||
* 切出一個 `## 標題` 段落。
|
||||
* 以整行比對而不是 indexOf:`## Java` 是 `## JavaScript` 的前綴,
|
||||
* 用 indexOf 會切到錯的那一節,而且切出來還是有內容的,錯得很安靜。
|
||||
*/
|
||||
function sectionOf(doc, heading) {
|
||||
const lines = doc.split('\n');
|
||||
const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
|
||||
if (start === -1) return null;
|
||||
|
||||
const rest = lines.slice(start + 1);
|
||||
const end = rest.findIndex((line) => line.startsWith('## '));
|
||||
return (end === -1 ? rest : rest.slice(0, end)).join('\n');
|
||||
}
|
||||
|
||||
// ── 實作規範 ───────────────────────────────────────────────────────
|
||||
|
||||
test('六種專案檔都對得到語言', () => {
|
||||
for (const file of [
|
||||
'\\*\\.csproj',
|
||||
'composer\\.json',
|
||||
'package\\.json',
|
||||
'go\\.mod',
|
||||
'pom\\.xml',
|
||||
'pyproject\\.toml',
|
||||
]) {
|
||||
assert.match(standards, new RegExp(file), `專案檔對照缺少 ${file}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('認不出語言時要停下來問,而且說明了為什麼不猜', () => {
|
||||
assert.match(standards, /認不出來就停下來問/);
|
||||
assert.match(standards, /不要猜/);
|
||||
assert.match(standards, /比沒有註解更難清理/, '要說出猜錯的代價,否則這條規則會被當成客套話');
|
||||
});
|
||||
|
||||
test('分層判定明講看職責不看目錄', () => {
|
||||
assert.match(standards, /看職責,不看目錄/);
|
||||
assert.match(standards, /目錄名稱會騙人/);
|
||||
});
|
||||
|
||||
test('三層各自要寫哪一種註解都寫明了', () => {
|
||||
for (const [layer, comment] of [
|
||||
['控制層', '功能註解'],
|
||||
['服務層', '邏輯註解'],
|
||||
['存取層', '資料源註解'],
|
||||
]) {
|
||||
const row = standards.split('\n').find((line) => line.includes(layer) && line.includes('|'));
|
||||
assert.ok(row, `${layer}沒有出現在分層表裡`);
|
||||
assert.match(row, new RegExp(comment), `${layer}要寫的是${comment}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('服務層要標註呼叫的方法,並說明理由是追呼叫鏈', () => {
|
||||
assert.match(standards, /標註它呼叫的所有方法/);
|
||||
assert.match(standards, /追得到呼叫鏈/);
|
||||
});
|
||||
|
||||
test('屬性註解要遞迴,而且明講不能只註解最外層', () => {
|
||||
assert.match(standards, /屬性本身是類別時遞迴處理/);
|
||||
assert.match(standards, /不能只註解最外層/);
|
||||
});
|
||||
|
||||
test('資料範例的來源有優先序,且未經驗證時要註明', () => {
|
||||
assert.match(standards, /優先從 MCP 取得/);
|
||||
assert.match(standards, /由邏輯推理、未經驗證/);
|
||||
assert.match(standards, /有人會照著那個格式寫解析/, '要說出不註明的代價');
|
||||
});
|
||||
|
||||
test('明講不寫入目標專案的任何檔案', () => {
|
||||
assert.match(standards, /不寫入目標專案的任何檔案/);
|
||||
assert.match(standards, /CLAUDE\.md/);
|
||||
});
|
||||
|
||||
// ── 註解格式對照表 ─────────────────────────────────────────────────
|
||||
|
||||
test('六種語言各有一節,且都附可照抄的程式碼範例', () => {
|
||||
for (const [language, marker] of [
|
||||
['C#', '///'],
|
||||
['PHP', '@var'],
|
||||
['JavaScript/TypeScript', 'JSDoc'],
|
||||
['Go', 'go doc'],
|
||||
['Java', 'Javadoc'],
|
||||
['Python', 'docstring'],
|
||||
]) {
|
||||
const body = sectionOf(styles, language);
|
||||
assert.ok(body, `對照表缺少 ${language}`);
|
||||
assert.match(body, new RegExp(marker.replace(/[/#]/g, '\\$&')), `${language} 缺少 ${marker}`);
|
||||
assert.match(body, /```/, `${language} 要有可照抄的範例,不要只用文字描述`);
|
||||
}
|
||||
});
|
||||
|
||||
test('每個語言的範例都同時示範了方法註解與屬性註解', () => {
|
||||
const sections = styles.split(/^## /m).filter((s) => s.includes('```'));
|
||||
for (const section of sections) {
|
||||
const name = section.split('\n')[0].trim();
|
||||
if (name === '未經驗證的範例怎麼標') continue;
|
||||
assert.match(section, /例:|例如/, `${name} 的範例要示範「附真實資料範例」這件事`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Go 的慣例(以識別字開頭)有被指出來,不是照抄別的語言', () => {
|
||||
assert.match(sectionOf(styles, 'Go'), /以被註解的識別字開頭/);
|
||||
});
|
||||
|
||||
test('Python 的 docstring 位置有講清楚在定義的下一行', () => {
|
||||
const python = sectionOf(styles, 'Python');
|
||||
assert.match(python, /下一行/);
|
||||
assert.match(python, /不是上一行/, '這是最容易寫錯的一點,要明講');
|
||||
});
|
||||
|
||||
test('未經驗證的註明怎麼寫,兩種語言各有一個可照抄的寫法', () => {
|
||||
const section = sectionOf(styles, '未經驗證的範例怎麼標');
|
||||
assert.match(section, /不要另起一行 TODO/);
|
||||
assert.ok((section.match(/由邏輯推理、未經驗證/g) ?? []).length >= 2, '至少要有兩種語言的寫法');
|
||||
});
|
||||
|
||||
// ── 兩份的分工 ─────────────────────────────────────────────────────
|
||||
|
||||
test('規範與格式分開:對照表不重複寫一遍規範', () => {
|
||||
assert.match(styles, /這份只管\*\*格式\*\*/);
|
||||
assert.match(standards, /comment-styles\.md/, '規範要指名去哪裡查格式');
|
||||
});
|
||||
@@ -103,3 +103,13 @@ export async function withStubGitea(t, routes) {
|
||||
|
||||
/** 把腳本指向這台假 Gitea 的環境變數 */
|
||||
export const stubEnv = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' });
|
||||
|
||||
/**
|
||||
* 找出腳本真正發出的那一個 PATCH。
|
||||
* 前置檢查對 `issues/0` 的探針也是 PATCH,但它打在一顆不存在的議題上、不改動任何東西,
|
||||
* 不該被當成腳本的寫入(見 lib.js 的 checkIssueWrite)。
|
||||
* @returns {object|undefined} 沒發出寫入時為 undefined
|
||||
*/
|
||||
export function patchOf(stub) {
|
||||
return stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/issues/0'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* 勾選待辦:以抽取契約給的 `raw` 做精確字串替換。
|
||||
*
|
||||
* 這一支的全部價值在「只動目標那一行」。改壞的代價很安靜——議題上的進度條會說謊,
|
||||
* 而沒有人會去比對 body 的編輯紀錄。所以三種危險各有測試:
|
||||
* - 同一句話在 body 裡出現兩次(巢狀待辦底下常有一模一樣的驗收,例如「加上測試」)
|
||||
* - `raw` 對不上(議題被人改過,手上的抽取結果已經過期)
|
||||
* - 已經勾過了(中斷後重跑)
|
||||
* 前兩種寧可報錯也不猜,第三種要安靜地當作沒事。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 12;
|
||||
|
||||
/**
|
||||
* 一份有巢狀待辦的工作包 body。刻意埋了三個地雷:
|
||||
* - 兩項驗收的文字一模一樣(同一段落內,真的分不出來)
|
||||
* - 架構圖是 fenced mermaid,裡面有一行長得像 checkbox
|
||||
* - 整體驗收裡有一行與待辦完全相同(不同段落,靠 --section 分得出來)
|
||||
*/
|
||||
const BODY = `## 架構圖
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart TD
|
||||
A[讀議題] --> B[勾待辦]
|
||||
- [ ] 解析九個段落
|
||||
\`\`\`
|
||||
|
||||
## 待辦
|
||||
|
||||
- [ ] 解析九個段落
|
||||
- [ ] 缺段落回空值
|
||||
- [ ] 加上測試
|
||||
- [ ] 待辦解析成巢狀結構
|
||||
- [ ] 加上測試
|
||||
|
||||
## 整體驗收
|
||||
|
||||
- [ ] 輸出欄位與契約完全一致
|
||||
- [ ] 待辦解析成巢狀結構
|
||||
`;
|
||||
|
||||
function routes(overrides = {}, { body = BODY } = {}) {
|
||||
return healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
|
||||
status: 200,
|
||||
body: { number: INDEX, title: '逐項實作並即時勾選待辦', body, html_url: 'https://example.com/12' },
|
||||
},
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: (req) => ({
|
||||
status: 200,
|
||||
body: { number: INDEX, ...req.body, html_url: 'https://example.com/12' },
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
|
||||
|
||||
/**
|
||||
* 把「待辦」段落裡的某一行換成指定寫法。
|
||||
* 不直接對整份 BODY 做 replace:圍欄裡那一行排在待辦之前,會被換掉的是它。
|
||||
*/
|
||||
function withTodoLine(from, to) {
|
||||
const at = BODY.indexOf('## 待辦');
|
||||
return BODY.slice(0, at) + BODY.slice(at).replace(from, to);
|
||||
}
|
||||
|
||||
const run = (args, stub) =>
|
||||
runScript('issue-update.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
|
||||
// ── 精確替換 ───────────────────────────────────────────────────────
|
||||
|
||||
test('勾起指定的那一行,其餘一字不動', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
const body = patchOf(stub).body.body;
|
||||
assert.match(body, /- \[x\] 解析九個段落/);
|
||||
assert.equal(
|
||||
body.replace('- [x] 解析九個段落', '- [ ] 解析九個段落'),
|
||||
BODY,
|
||||
'把那一個方框換回去之後,應該逐字等於原本的 body',
|
||||
);
|
||||
});
|
||||
|
||||
test('縮排的驗收項目也勾得到,縮排原樣保留', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code } = await run(['--tick', ' - [ ] 缺段落回空值', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.match(patchOf(stub).body.body, /\n {2}- \[x\] 缺段落回空值\n/);
|
||||
});
|
||||
|
||||
test('回報勾起來的是哪一行,讓呼叫端印進度', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(json.data.勾起的那一行, '- [x] 解析九個段落');
|
||||
assert.equal(json.data.已經勾過, false);
|
||||
});
|
||||
|
||||
// ── 文字重複時不誤傷 ───────────────────────────────────────────────
|
||||
|
||||
test('同一句話在 body 裡出現兩次時報錯,不賭第一個', async (t) => {
|
||||
// 巢狀待辦底下常有一模一樣的驗收;猜錯的話,議題上的進度條會指著錯的那一項
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--tick', ' - [ ] 加上測試', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'RAW_AMBIGUOUS');
|
||||
assert.match(json.error.message, /2/, '要說出它出現了幾次');
|
||||
assert.equal(patchOf(stub), undefined, '分不出是哪一行就不要寫');
|
||||
});
|
||||
|
||||
// ── raw 對不上 ─────────────────────────────────────────────────────
|
||||
|
||||
test('raw 不匹配時回錯誤,不盲改', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--tick', '- [ ] 這一行議題上沒有', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'RAW_NOT_FOUND');
|
||||
assert.match(json.error.message, /重新抽取|過期/, '要指出手上的抽取結果可能過期了');
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
});
|
||||
|
||||
test('差一個空白也算對不上:精確替換就是要精確', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'RAW_NOT_FOUND');
|
||||
});
|
||||
|
||||
// ── 冪等:中斷後重跑 ───────────────────────────────────────────────
|
||||
|
||||
test('已經勾過的項目不再動它,也不發 PATCH', async (t) => {
|
||||
const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落');
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code, json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0, '重跑不該失敗,那會讓中斷後的接續變成人工作業');
|
||||
assert.equal(json.data.已經勾過, true);
|
||||
assert.equal(patchOf(stub), undefined, '沒有變化就不要在議題上留下一筆空的編輯');
|
||||
});
|
||||
|
||||
test('直接給已勾的那一行也算數,同樣是 no-op', async (t) => {
|
||||
const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落');
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code, json } = await run(['--tick', '- [x] 解析九個段落', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.已經勾過, true);
|
||||
});
|
||||
|
||||
test('大寫的 [X] 重跑時也是安靜的 no-op,不是 RAW_NOT_FOUND', async (t) => {
|
||||
// [X] 是合法的 GFM,Gitea 會把它渲染成已勾,wp-extract 也回報 done:true。
|
||||
// 比對時若只認小寫,中斷後重跑會硬失敗,而錯誤訊息還會誣指「議題被改過」。
|
||||
const body = withTodoLine('- [ ] 待辦解析成巢狀結構', '- [X] 待辦解析成巢狀結構');
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code, json } = await run(['--tick', '- [X] 待辦解析成巢狀結構', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(json.data.已經勾過, true);
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
});
|
||||
|
||||
// ── 輸入驗證 ───────────────────────────────────────────────────────
|
||||
|
||||
test('--tick 的內容根本不是清單項時擋下', async (t) => {
|
||||
// 「是清單項但忘了寫方框」是另一種情況,錯誤碼不同——那種要指路去議題上補
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--tick', '解析九個段落'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_RAW');
|
||||
assert.match(json.error.message, /清單項/);
|
||||
});
|
||||
|
||||
test('--section 沒有配 --tick 時說清楚它沒有作用', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--section', '待辦', '--milestone', '第一階段'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
assert.match(json.error.message, /--tick/);
|
||||
});
|
||||
|
||||
test('--tick 夾帶換行時擋下:一次只勾一行', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--tick', '- [ ] 甲\n- [ ] 乙'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_RAW');
|
||||
});
|
||||
|
||||
// ── 圍欄與段落:不誤傷、也不假歧義 ─────────────────────────────────
|
||||
|
||||
test('圍欄裡長得像 checkbox 的那一行不算,不會被改到', async (t) => {
|
||||
// 工作包模板的架構圖就是一塊 fenced mermaid,裡面出現減號開頭的行是常態。
|
||||
// issue-body.js 全檔的前提是「圍欄裡的東西不是內容」,勾選是唯一會寫回去的路徑,
|
||||
// 漏掉這件事就會靜靜改壞圖。
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
const body = patchOf(stub).body.body;
|
||||
const fence = body.slice(body.indexOf('```mermaid'), body.indexOf('## 待辦'));
|
||||
assert.match(fence, /- \[ \] 解析九個段落/, '圍欄裡那一行要原封不動');
|
||||
});
|
||||
|
||||
test('不同段落有同一行時,--section 分得出來', async (t) => {
|
||||
// 待辦與整體驗收各有一行「待辦解析成巢狀結構」,限定段落就不該是歧義
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--tick', '- [ ] 待辦解析成巢狀結構', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
const body = patchOf(stub).body.body;
|
||||
const todo = body.slice(body.indexOf('## 待辦'), body.indexOf('## 整體驗收'));
|
||||
const overall = body.slice(body.indexOf('## 整體驗收'));
|
||||
assert.match(todo, /- \[x\] 待辦解析成巢狀結構/, '待辦那一行要被勾起');
|
||||
assert.match(overall, /- \[ \] 待辦解析成巢狀結構/, '整體驗收那一行不該被動到');
|
||||
});
|
||||
|
||||
test('整體驗收段落也勾得到,各勾各的', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code } = await run(['--tick', '- [ ] 待辦解析成巢狀結構', '--section', '整體驗收'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
const body = patchOf(stub).body.body;
|
||||
const todo = body.slice(body.indexOf('## 待辦'), body.indexOf('## 整體驗收'));
|
||||
assert.match(todo, /- \[ \] 待辦解析成巢狀結構/, '待辦那一行不該被動到');
|
||||
assert.match(body.slice(body.indexOf('## 整體驗收')), /- \[x\] 待辦解析成巢狀結構/);
|
||||
});
|
||||
|
||||
test('--section 指到不存在的段落時報錯,不退回掃全文', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '沒有這一段'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'SECTION_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('沒給 --section 時掃全文,但圍欄照樣不算', async (t) => {
|
||||
const body = '## 待辦\n\n```\n- [ ] 圍欄裡的假待辦\n```\n\n- [ ] 真正的待辦\n';
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code } = await run(['--tick', '- [ ] 圍欄裡的假待辦'], stub);
|
||||
|
||||
assert.equal(code, 1, '圍欄裡的行不是內容,找不到才對');
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
});
|
||||
|
||||
// ── 抽取端與勾選端要對得上 ─────────────────────────────────────────
|
||||
|
||||
test('方框後面沒有空白也勾得到:抽取端收得下的,勾選端就要收得下', async (t) => {
|
||||
// parseChecklistItem 的文法允許 `- [ ]甲`,wp-extract 會照樣交出它的 raw;
|
||||
// 勾選端若比抽取端嚴格,正本那句「一律用 wp-extract 給的 raw」就變成做不到的事
|
||||
const body = '## 待辦\n\n- [ ]沒有空白的那一項\n';
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code, json } = await run(['--tick', '- [ ]沒有空白的那一項', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.match(patchOf(stub).body.body, /- \[x\]沒有空白的那一項/);
|
||||
});
|
||||
|
||||
test('議題上那一項根本沒有 checkbox 時,錯誤要說清楚而不是謊報已勾過', async (t) => {
|
||||
// wp-extract 會把 `- 忘了寫 checkbox` 當成一項待辦(done:false),
|
||||
// 但那一行沒有方框可以換。這時要說「去議題上補成 checkbox」,不能回報「已經勾過」
|
||||
const body = '## 待辦\n\n- 忘了寫 checkbox 的待辦\n';
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code, json } = await run(['--tick', '- 忘了寫 checkbox 的待辦', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'NOT_A_CHECKBOX');
|
||||
assert.match(json.error.message, /補/, '要告訴使用者去議題上把它補成 checkbox');
|
||||
});
|
||||
|
||||
// ── 與既有欄位共存 ─────────────────────────────────────────────────
|
||||
|
||||
test('--tick 可以和別的欄位一起送,共用同一個 PATCH', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/milestones`]: { status: 200, body: [{ id: 3, title: '第一階段' }] },
|
||||
});
|
||||
|
||||
const { code } = await run(
|
||||
['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--milestone', '第一階段'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
const patch = patchOf(stub);
|
||||
assert.match(patch.body.body, /- \[x\] 解析九個段落/);
|
||||
assert.equal(patch.body.milestone, 3);
|
||||
});
|
||||
|
||||
test('什麼都沒指定時仍然報 NOTHING_TO_UPDATE', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.equal(json.error.code, 'NOTHING_TO_UPDATE');
|
||||
assert.match(json.error.message, /--tick/, '新欄位也要列進可用清單');
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出改完的 body,但不寫進去', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(
|
||||
['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--dry-run'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.match(json.data.requests[0].body.body, /- \[x\] 解析九個段落/);
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
});
|
||||
|
||||
test('--dry-run 在已經勾過時要說「實跑不會發任何請求」', async (t) => {
|
||||
// 試跑印出一個 PATCH、實跑卻什麼都不送,是最難查的那種落差
|
||||
const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落');
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { json } = await run(
|
||||
['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--dry-run'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.data.已經勾過, true);
|
||||
assert.deepEqual(json.data.requests, [], '沒有東西要改,預告的請求就該是空的');
|
||||
});
|
||||
|
||||
test('--dry-run 遇到分不清的 raw 一樣報錯,不會等到實跑才發現', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--tick', ' - [ ] 加上測試', '--section', '待辦', '--dry-run'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'RAW_AMBIGUOUS');
|
||||
});
|
||||
|
||||
// ── CRLF 的 body ───────────────────────────────────────────────────
|
||||
|
||||
test('CRLF 的 body 也勾得到,行尾的 \\r 不被吃掉', async (t) => {
|
||||
// 議題只要在 Gitea 網頁上被編輯過就是 CRLF;wp-extract 交出的 raw 會連 \r 一起帶著
|
||||
const body = BODY.replace(/\n/g, '\r\n');
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code } = await run(['--tick', '- [ ] 解析九個段落\r', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.match(patchOf(stub).body.body, /- \[x\] 解析九個段落\r\n/);
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
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';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 12;
|
||||
@@ -39,7 +39,6 @@ const run = (args, stub) =>
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
const patchOf = (stub) => stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/0'));
|
||||
|
||||
// ── Milestone ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -132,7 +131,24 @@ test('估算沒有變時不重寫 body', async (t) => {
|
||||
|
||||
await run(['--estimate-days', '3'], stub);
|
||||
|
||||
assert.equal('body' in patchOf(stub).body, false, '沒變就不該把 body 塞進 PATCH');
|
||||
assert.equal(
|
||||
patchOf(stub),
|
||||
undefined,
|
||||
'沒有任何欄位要改就整個 PATCH 都不發:空的 PATCH 會把議題的 updated_at 推新,'
|
||||
+ '在列表上浮起來像是有人動過',
|
||||
);
|
||||
});
|
||||
|
||||
test('有別的欄位要改時照樣發 PATCH,但沒變的 body 不跟著被重寫', async (t) => {
|
||||
const stub = await withStub(t, {}, {
|
||||
body: '## 關聯\n\n需求議題:#1\n估算人天:3\n',
|
||||
});
|
||||
|
||||
await run(['--estimate-days', '3', '--milestone', '第一階段'], stub);
|
||||
|
||||
const patch = patchOf(stub);
|
||||
assert.equal(patch.body.milestone, 3);
|
||||
assert.equal('body' in patch.body, false, '估算沒變,body 就不該被塞進去');
|
||||
});
|
||||
|
||||
test('人天必須是正數', async (t) => {
|
||||
@@ -305,7 +321,7 @@ test('連結沒變時不重寫 body', async (t) => {
|
||||
|
||||
await run(['--overview-url', 'https://example.com/a'], stub);
|
||||
|
||||
assert.equal('body' in patchOf(stub).body, false);
|
||||
assert.equal(patchOf(stub), undefined, '連結沒變、也沒有別的欄位要改,就不發 PATCH');
|
||||
});
|
||||
|
||||
test('不是網址時擋在打 Gitea 之前', async (t) => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
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';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 12;
|
||||
@@ -39,7 +39,6 @@ const run = (args, stub) =>
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
const patchOf = (stub) => stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/0'));
|
||||
|
||||
// ── 以名稱指定 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ 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 phase1 = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## 第二段'));
|
||||
const phase2 = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 邊界'));
|
||||
|
||||
test('正本平台中立,description 前綴正確', () => {
|
||||
assertNeutralPrompt(prompt, 'sdlc-feat');
|
||||
@@ -95,3 +96,87 @@ test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔',
|
||||
assert.match(boundary, /不寫任何本機狀態檔/);
|
||||
assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔');
|
||||
});
|
||||
|
||||
// ── 第二段:逐項實作 ───────────────────────────────────────────────
|
||||
|
||||
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('### 8.');
|
||||
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('### 11.'));
|
||||
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, /不改與待辦無關的程式碼/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user