feat: BDD Contract

This commit is contained in:
2026-05-24 10:38:17 +08:00
parent c8ff825722
commit da9e388a11
6 changed files with 1048 additions and 1 deletions
+6
View File
@@ -35,14 +35,20 @@ test('CLI runs doctor and scan against one prototype', async () => {
await exec('node', [cli, 'scan'], { cwd })
const contract = await readFile(join(cwd, '.ht/spec/index.ui-contract.md'), 'utf8')
const bdd = await readFile(join(cwd, '.ht/spec/index.bdd.md'), 'utf8')
const spec = JSON.parse(await readFile(join(cwd, '.ht/spec/index.spec.json'), 'utf8'))
const validation = JSON.parse(await readFile(join(cwd, '.ht/spec/index.validation.json'), 'utf8'))
const appMap = JSON.parse(await readFile(join(cwd, '.ht/app-map.json'), 'utf8'))
assert.match(doctor.stdout, /ok prototype directory/)
assert.match(contract, /Customer Portal/)
assert.match(contract, /BDD Scenarios/)
assert.match(bdd, /Feature: Customer portal entry/)
assert.match(bdd, /Scenario: 使用者填寫必要資料並送出成功/)
assert.doesNotMatch(contract, /Recommended template/)
assert.equal(spec.pageContract.title, null)
assert.equal(spec.bddContract.feature, 'Customer portal entry')
assert.equal(spec.bddContract.scenarios[0].type, 'application-submit')
assert.deepEqual(pick(spec.pageContract.forms[0].fields[0], ['name', 'label', 'type', 'required']), {
name: 'email',
label: 'Email',
+125
View File
@@ -1,6 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { buildApiCatalog, matchApiEndpointCandidates, matchApiEndpoints } from '../src/lib/api-docs.js'
import { buildBddContract, renderBddContract } from '../src/lib/bdd.js'
import { buildMaintenanceContract } from '../src/lib/maintenance.js'
import { buildAppMap, buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../src/lib/html.js'
@@ -409,6 +410,130 @@ test('buildMaintenanceContract ignores non-vue guide text for primary entity', (
assert.deepEqual(contract.capabilities, ['print'])
})
test('buildBddContract creates traceable auth scenarios from page evidence', () => {
const spec = buildSpec('/repo/prototype/portal/login.html', `
<main>
<h1>使用者登入</h1>
<form>
<label>帳號</label><input name="account" required>
<label>密碼</label><input name="password" type="password" required>
<button>登入</button>
</form>
</main>
`)
spec.maintenanceContract = {
pageKind: 'login',
capabilities: ['custom'],
dataModel: { primaryEntity: 'Login' },
businessRules: { validationRules: [{ ruleType: 'required', text: '帳號與密碼必填' }] },
rowActions: []
}
spec.prototypeGuide = {
description: '使用者登入',
checklist: ['登入失敗時顯示錯誤訊息'],
flowRefs: [{ title: '登入流程', nodes: [{ nodeType: 'submit', action: '登入' }] }]
}
spec.apiContract = {
endpoints: [{ method: 'POST', path: '/api/auth/login', usage: 'submit', description: '使用者登入' }]
}
const contract = buildBddContract(spec)
const markdown = renderBddContract(spec, contract)
assert.equal(contract.feature, '使用者登入')
assert.equal(contract.scenarios[0].name, '使用者輸入正確帳密登入成功')
assert.deepEqual(contract.scenarios[0].given, ['使用者已在使用者登入頁'])
assert.deepEqual(contract.scenarios[0].when, ['使用者輸入正確的帳號與密碼並執行「登入」'])
assert.ok(contract.scenarios[0].then.includes('系統應送出 POST /api/auth/login'))
assert.deepEqual(contract.scenarios[0].evidence.fields, ['帳號', '密碼'])
assert.deepEqual(contract.scenarios[0].evidence.checklist, ['登入失敗時顯示錯誤訊息'])
assert.equal(contract.requiresHumanReview, true)
assert.match(markdown, /Feature: 使用者登入/)
assert.match(markdown, /Scenario: 使用者輸入正確帳密登入成功/)
assert.match(markdown, /Then 系統應送出 POST \/api\/auth\/login/)
assert.match(markdown, /## Evidence/)
})
test('buildBddContract creates query and row action scenarios with review warnings', () => {
const spec = buildSpec('/repo/prototype/venue/applications-list.html', `
<main>
<h1>我的申請紀錄</h1>
<form><label>狀態</label><select name="status"><option>審核中</option></select><button>查詢</button></form>
<table>
<tr><th>申請單號</th><th>狀態</th><th>操作</th></tr>
<tr><td>A001</td><td>審核中</td><td><input type="button" value="修改"><input type="button" value="刪除"></td></tr>
</table>
</main>
`)
spec.maintenanceContract = {
pageKind: 'maintenance',
capabilities: ['search', 'edit', 'delete'],
dataModel: { primaryEntity: 'ApplicationsList' },
businessRules: { validationRules: [], statusRules: [{ ruleType: 'enabled-when-status', field: 'aprvYn', value: 'Z', text: 'aprvYn=Z 才可修改刪除' }] },
rowActions: [
{ label: '修改', actionType: 'edit', enabledWhen: "aprvYn === 'Z'" },
{ label: '刪除', actionType: 'delete', enabledWhen: "aprvYn === 'Z'" }
]
}
spec.apiContract = {
endpoints: [
{ method: 'GET', path: '/api/applications', usage: 'query', description: '查詢申請紀錄' },
{ method: 'DELETE', path: '/api/applications/{id}', usage: 'delete', description: '刪除申請' }
]
}
const contract = buildBddContract(spec)
assert.equal(contract.scenarios[0].type, 'query')
assert.deepEqual(contract.scenarios[0].then, ['系統應送出 GET /api/applications', '顯示符合條件的查詢結果'])
assert.ok(contract.scenarios.some((scenario) => scenario.name === '符合狀態條件時可以修改資料列'))
assert.ok(contract.scenarios.some((scenario) => scenario.name === '符合狀態條件時可以刪除資料列'))
assert.ok(contract.warnings.some((warning) => warning.includes('Then')))
})
test('buildBddContract reports scenario candidates and uncovered evidence', () => {
const spec = buildSpec('/repo/prototype/portal/login.html', `
<main>
<h1>使用者登入</h1>
<form>
<label>帳號</label><input name="account" required>
<label>密碼</label><input name="password" type="password" required>
<button>登入</button>
<button>忘記密碼</button>
</form>
</main>
`)
spec.maintenanceContract = {
pageKind: 'login',
capabilities: ['custom'],
dataModel: { primaryEntity: 'Login' },
businessRules: { validationRules: [{ ruleType: 'required', text: '帳號與密碼必填' }] },
rowActions: []
}
spec.prototypeGuide = {
description: '使用者登入',
checklist: ['登入失敗時顯示錯誤訊息', '連續錯誤三次鎖定帳號'],
flowRefs: [{ title: '登入失敗流程', nodes: [{ nodeType: 'error', action: '顯示錯誤訊息' }] }]
}
spec.apiContract = {
endpoints: [{ method: 'POST', path: '/api/auth/login', usage: 'submit', description: '使用者登入' }],
errorHandling: { formats: [{ status: 401, description: '帳號或密碼錯誤' }] }
}
const contract = buildBddContract(spec)
const markdown = renderBddContract(spec, contract)
assert.ok(contract.candidateScenarios.some((candidate) => candidate.type === 'error-path'))
assert.ok(contract.candidateScenarios.some((candidate) => candidate.source === 'api-error'))
assert.ok(contract.uncoveredEvidence.some((item) => item.source === 'prototypeGuide.checklist' && item.text === '連續錯誤三次鎖定帳號'))
assert.ok(contract.uncoveredEvidence.some((item) => item.source === 'pageContract.actions' && item.text === '忘記密碼'))
assert.equal(contract.requiresHumanReview, true)
assert.match(markdown, /## Candidate Scenarios/)
assert.match(markdown, /登入失敗時顯示錯誤訊息/)
assert.match(markdown, /## Uncovered Evidence/)
assert.match(markdown, /連續錯誤三次鎖定帳號/)
})
function buildSpec(source, html) {
const regions = extractRegions(html)
const domSummary = summarizeHtml(html)