/** * 工作包議題的模板,以及正本裡「產生工作包」那一段的規則。 * * 模板的段落順序決定 #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/); });