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
+429
View File
@@ -0,0 +1,429 @@
export function buildBddContract(spec) {
const pageKind = spec.maintenanceContract?.pageKind ?? 'page'
const feature = inferFeature(spec)
const scenarios = buildScenarios(spec, pageKind, feature)
const candidateScenarios = buildCandidateScenarios(spec, scenarios, feature)
const uncoveredEvidence = buildUncoveredEvidence(spec, scenarios, candidateScenarios)
const warnings = scenarios.flatMap((scenario) => scenario.warnings.map((warning) => `${scenario.name}: ${warning}`))
if (scenarios.length === 0) warnings.push('缺少可產生 BDD scenario 的表單、表格或操作 evidence')
for (const candidate of candidateScenarios) warnings.push(`候選 scenario 待人工確認:${candidate.name}`)
for (const item of uncoveredEvidence) warnings.push(`未覆蓋 evidence${item.source} - ${item.text}`)
return {
feature,
pageKind,
source: spec.source,
requiresHumanReview: warnings.length > 0,
scenarios,
candidateScenarios,
uncoveredEvidence,
warnings
}
}
export function renderBddContract(spec, contract = spec.bddContract) {
const lines = [`# BDD Contract: ${spec.page}`, '']
lines.push(`Feature: ${contract.feature}`)
lines.push('')
for (const scenario of contract.scenarios) {
lines.push(`Scenario: ${scenario.name}`)
for (const step of scenario.given) lines.push(` Given ${step}`)
for (const step of scenario.when) lines.push(` When ${step}`)
scenario.then.forEach((step, index) => lines.push(` ${index === 0 ? 'Then' : 'And'} ${step}`))
lines.push('')
}
lines.push('## Evidence', '')
for (const scenario of contract.scenarios) {
lines.push(`### ${scenario.name}`)
lines.push(`- Type: ${scenario.type}`)
lines.push(`- Fields: ${scenario.evidence.fields.join(', ') || 'none'}`)
lines.push(`- Actions: ${scenario.evidence.actions.join(', ') || 'none'}`)
lines.push(`- Tables: ${scenario.evidence.tables.join(', ') || 'none'}`)
lines.push(`- Endpoints: ${scenario.evidence.endpoints.join(', ') || 'none'}`)
lines.push(`- Checklist: ${scenario.evidence.checklist.join(', ') || 'none'}`)
lines.push(`- Flow refs: ${scenario.evidence.flowRefs.join(', ') || 'none'}`)
if (scenario.warnings.length) {
lines.push(`- Warnings: ${scenario.warnings.join('; ')}`)
}
lines.push('')
}
lines.push('## Candidate Scenarios', '')
if ((contract.candidateScenarios ?? []).length === 0) lines.push('- none')
for (const candidate of contract.candidateScenarios ?? []) {
lines.push(`- ${candidate.type}: ${candidate.name}`)
lines.push(` - Source: ${candidate.source}`)
lines.push(` - Evidence: ${candidate.evidenceText}`)
lines.push(` - Reason: ${candidate.reason}`)
}
lines.push('')
lines.push('## Uncovered Evidence', '')
if ((contract.uncoveredEvidence ?? []).length === 0) lines.push('- none')
for (const item of contract.uncoveredEvidence ?? []) {
lines.push(`- ${item.source}: ${item.text} (${item.reason})`)
}
lines.push('')
lines.push('## Warnings', '')
if (contract.warnings.length === 0) lines.push('- none')
for (const warning of contract.warnings) lines.push(`- ${warning}`)
return `${lines.join('\n')}\n`
}
function buildScenarios(spec, pageKind, feature) {
if (isAuthPage(spec, pageKind)) return buildAuthScenarios(spec, feature)
if (pageKind === 'print') return buildPrintScenarios(spec, feature)
if (pageKind === 'maintenance') return buildMaintenanceScenarios(spec, feature)
if (pageKind === 'application') return buildApplicationScenarios(spec, feature)
if (pageKind === 'query') return buildQueryScenarios(spec, feature)
return buildGenericScenarios(spec, feature)
}
function buildAuthScenarios(spec, feature) {
const action = preferredAction(spec, ['save', 'custom'])
const fields = fieldLabels(spec)
const endpoint = endpointFor(spec, ['submit', 'login'], ['POST'])
return [scenario({
name: '使用者輸入正確帳密登入成功',
type: 'auth-success',
given: [`使用者已在${pageName(feature)}`],
when: [`使用者輸入正確的${credentialPhrase(fields)}並執行「${action?.label ?? '登入'}`],
then: [
endpoint ? `系統應送出 ${endpoint.method} ${endpoint.path}` : '系統應送出登入請求',
'系統應導向登入後頁面',
'顯示登入成功結果'
],
evidence: evidence(spec, { fields, actions: action ? [action.label] : [], endpoints: endpoint ? [endpoint] : [] }),
warnings: [
...missingEvidence(endpoint, '登入 API'),
'Then 的導向頁與成功訊息需依實作或 legacy flow 人工確認'
]
})]
}
function buildMaintenanceScenarios(spec, feature) {
return [
...buildQueryScenarios(spec, feature),
...rowActionScenarios(spec, feature)
]
}
function buildApplicationScenarios(spec, feature) {
const action = preferredAction(spec, ['save', 'create'])
const fields = requiredFieldLabels(spec)
const endpoint = endpointFor(spec, ['submit', 'create'], ['POST', 'PUT', 'PATCH'])
return [scenario({
name: '使用者填寫必要資料並送出成功',
type: 'application-submit',
given: [`使用者已在${pageName(feature)}`],
when: [`使用者填寫${listPhrase(fields, '必要欄位')}並執行「${action?.label ?? '送出'}`],
then: [
endpoint ? `系統應送出 ${endpoint.method} ${endpoint.path}` : '系統應送出表單資料',
'顯示送出成功結果'
],
evidence: evidence(spec, { fields, actions: action ? [action.label] : [], endpoints: endpoint ? [endpoint] : [] }),
warnings: [
...missingEvidence(endpoint, '送出 API'),
'Then 的成功訊息需人工確認'
]
})]
}
function buildQueryScenarios(spec, feature) {
const action = preferredAction(spec, ['search']) ?? preferredAction(spec, ['custom'])
const fields = fieldLabels(spec)
const endpoint = endpointFor(spec, ['query', 'lookup'], ['GET'])
const hasResultTable = spec.pageContract.tables.some((table) => table.role === 'resultTable')
return [scenario({
name: '使用者輸入條件查詢資料',
type: 'query',
given: [`使用者已在${pageName(feature)}`],
when: [`使用者輸入${listPhrase(fields, '查詢條件')}並執行「${action?.label ?? '查詢'}`],
then: [
endpoint ? `系統應送出 ${endpoint.method} ${endpoint.path}` : '系統應送出查詢請求',
hasResultTable ? '顯示符合條件的查詢結果' : '顯示查詢結果'
],
evidence: evidence(spec, { fields, actions: action ? [action.label] : [], endpoints: endpoint ? [endpoint] : [] }),
warnings: [
...missingEvidence(endpoint, '查詢 API'),
!hasResultTable ? '缺少結果表格 evidence' : null,
'Then 的查詢結果狀態需人工確認'
].filter(Boolean)
})]
}
function rowActionScenarios(spec, feature) {
return (spec.maintenanceContract?.rowActions ?? []).map((action) => {
const endpoint = endpointFor(spec, [action.actionType], methodCandidates(action.actionType))
return scenario({
name: action.enabledWhen ? `符合狀態條件時可以${action.label}資料列` : `使用者可以${action.label}資料列`,
type: `row-${action.actionType}`,
given: [`使用者已在${pageName(feature)}`, action.enabledWhen ? `資料列符合 ${action.enabledWhen}` : '資料列已顯示在結果列表'],
when: [`使用者在資料列執行「${action.label}`],
then: [
endpoint ? `系統應送出 ${endpoint.method} ${endpoint.path}` : `系統應執行${action.label}流程`,
`顯示${action.label}後的結果`
],
evidence: evidence(spec, { actions: [action.label], endpoints: endpoint ? [endpoint] : [] }),
warnings: [
...missingEvidence(endpoint, `${action.label} API`),
'Then 的資料列狀態變化需人工確認'
]
})
})
}
function buildPrintScenarios(spec, feature) {
const action = preferredAction(spec, ['print'])
const endpoint = endpointFor(spec, ['print'], ['GET'])
return [scenario({
name: '使用者列印頁面資料',
type: 'print',
given: [`使用者已在${pageName(feature)}`],
when: [`使用者執行「${action?.label ?? '列印'}`],
then: [
endpoint ? `系統應送出 ${endpoint.method} ${endpoint.path}` : '系統應開啟列印流程',
'顯示可列印內容'
],
evidence: evidence(spec, { actions: action ? [action.label] : [], endpoints: endpoint ? [endpoint] : [] }),
warnings: [
...missingEvidence(endpoint, '列印 API'),
'Then 的列印視窗或下載行為需人工確認'
]
})]
}
function buildGenericScenarios(spec, feature) {
const action = preferredAction(spec, ['save', 'create', 'custom'])
if (!action && spec.pageContract.tables.length > 0) return buildQueryScenarios(spec, feature)
if (!action) return []
const fields = fieldLabels(spec)
return [scenario({
name: '使用者送出表單',
type: 'form-submit',
given: [`使用者已在${pageName(feature)}`],
when: [`使用者填寫${listPhrase(fields, '表單資料')}並執行「${action.label}`],
then: ['系統應處理表單送出結果'],
evidence: evidence(spec, { fields, actions: [action.label] }),
warnings: []
})]
}
function scenario(value) {
return {
name: value.name,
type: value.type,
given: value.given,
when: value.when,
then: value.then,
evidence: value.evidence,
warnings: value.warnings.filter(Boolean)
}
}
function buildCandidateScenarios(spec, scenarios, feature) {
const candidates = []
for (const item of spec.prototypeGuide?.checklist ?? []) {
const candidate = candidateFromText(item, 'prototypeGuide.checklist', feature)
if (candidate && !isCoveredByScenarios(item, scenarios)) candidates.push(candidate)
}
for (const flow of spec.prototypeGuide?.flowRefs ?? []) {
const text = [flow.title, ...(flow.nodes ?? []).map((node) => `${node.nodeType ?? ''} ${node.action ?? ''}`)].join(' ')
const candidate = candidateFromText(text, 'prototypeGuide.flowRefs', feature)
if (candidate && !isCoveredByScenarios(text, scenarios)) candidates.push(candidate)
}
for (const format of errorFormats(spec)) {
const text = [format.status, format.code, format.message, format.description].filter(Boolean).join(' ')
const candidate = candidateFromText(text, 'api-error', feature)
if (candidate) candidates.push(candidate)
}
return dedupeByKey(candidates, (candidate) => `${candidate.type}:${candidate.source}:${candidate.evidenceText}`)
}
function buildUncoveredEvidence(spec, scenarios, candidateScenarios) {
const coveredText = [
...scenarios.flatMap((item) => [
item.name,
...item.given,
...item.when,
...item.then,
...item.evidence.fields,
...item.evidence.actions,
...item.evidence.endpoints
]),
...candidateScenarios.flatMap((item) => [item.name, item.evidenceText])
].join(' ')
const uncovered = []
for (const item of spec.prototypeGuide?.checklist ?? []) {
if (!textIncludesEvidence(coveredText, item)) {
uncovered.push({ source: 'prototypeGuide.checklist', text: item, reason: 'checklist 尚未對應到正式或候選 scenario' })
}
}
for (const action of spec.pageContract.actions ?? []) {
if (!textIncludesEvidence(coveredText, action.label)) {
uncovered.push({ source: 'pageContract.actions', text: action.label, reason: 'action 尚未對應到正式或候選 scenario' })
}
}
for (const endpoint of spec.apiContract?.endpoints ?? []) {
const text = `${endpoint.method} ${endpoint.path}`
if (!textIncludesEvidence(coveredText, text)) {
uncovered.push({ source: 'apiContract.endpoints', text, reason: 'API endpoint 尚未被 scenario 使用' })
}
}
return uncovered
}
function candidateFromText(text, source, feature) {
if (/(錯誤|失敗|error|invalid|unauthorized|401|403|400)/i.test(text)) {
return {
name: `${pageName(feature)}失敗時顯示錯誤結果`,
type: 'error-path',
source,
evidenceText: text,
reason: '偵測到錯誤或失敗流程 evidence'
}
}
if (/(必填|required|不可空白|不可空)/i.test(text)) {
return {
name: `${pageName(feature)}缺少必要資料時顯示驗證結果`,
type: 'validation',
source,
evidenceText: text,
reason: '偵測到欄位驗證 evidence'
}
}
if (/(disabled|停用|不可|不能|隱藏|不顯示|才(?:能|可)|狀態|aprvYn)/i.test(text)) {
return {
name: `${pageName(feature)}依狀態限制操作`,
type: 'state-rule',
source,
evidenceText: text,
reason: '偵測到狀態或操作限制 evidence'
}
}
return null
}
function evidence(spec, overrides = {}) {
return {
fields: overrides.fields ?? [],
actions: overrides.actions ?? [],
tables: spec.pageContract.tables.map((table) => `${table.id}:${table.role}`),
endpoints: (overrides.endpoints ?? []).map((endpoint) => `${endpoint.method} ${endpoint.path}`),
checklist: spec.prototypeGuide?.checklist ?? [],
flowRefs: (spec.prototypeGuide?.flowRefs ?? []).map((flow) => flow.title).filter(Boolean)
}
}
function errorFormats(spec) {
const handling = spec.apiContract?.errorHandling
if (!handling) return []
if (Array.isArray(handling.formats)) return handling.formats
if (Array.isArray(handling.errors)) return handling.errors
if (Array.isArray(handling)) return handling
return []
}
function isCoveredByScenarios(text, scenarios) {
return textIncludesEvidence(scenarios.flatMap((scenario) => [
scenario.name,
...scenario.given,
...scenario.when,
...scenario.then,
...scenario.evidence.fields,
...scenario.evidence.actions,
...scenario.evidence.checklist,
...scenario.evidence.flowRefs
]).join(' '), text)
}
function textIncludesEvidence(haystack, needle) {
const normalizedHaystack = normalizeForCoverage(haystack)
const normalizedNeedle = normalizeForCoverage(needle)
if (!normalizedNeedle) return true
if (normalizedHaystack.includes(normalizedNeedle)) return true
const keywords = normalizedNeedle.split(/\s+/).filter((word) => word.length >= 2)
if (keywords.length === 0) return true
return keywords.some((word) => normalizedHaystack.includes(word))
}
function normalizeForCoverage(text) {
return String(text ?? '').replace(/[「」『』`'"()[\]{}:,.。;/\\\s]+/g, ' ').trim().toLowerCase()
}
function inferFeature(spec) {
if (isAuthPage(spec, spec.maintenanceContract?.pageKind) && /login|登入/i.test(spec.page)) return '使用者登入'
return cleanName(spec.prototypeGuide?.description) ??
cleanName(spec.pageContract.title) ??
cleanName(spec.pageContract.sections.find((section) => section.name)?.name) ??
spec.page.replace(/\.html$/i, '')
}
function isAuthPage(spec, pageKind) {
return ['auth', 'login'].includes(pageKind) || spec.pageContract.forms.some((form) => form.fields.some((field) => field.type === 'password'))
}
function preferredAction(spec, actionTypes) {
return spec.pageContract.actions.find((action) => actionTypes.includes(action.actionType)) ?? null
}
function fieldLabels(spec) {
return unique(spec.pageContract.forms.flatMap((form) => form.fields.map((field) => field.label ?? field.name).filter(Boolean)))
}
function requiredFieldLabels(spec) {
const fields = unique(spec.pageContract.forms.flatMap((form) => form.fields.filter((field) => field.required).map((field) => field.label ?? field.name).filter(Boolean)))
return fields.length > 0 ? fields : fieldLabels(spec)
}
function endpointFor(spec, usages, methods) {
const endpoints = spec.apiContract?.endpoints ?? []
return endpoints.find((endpoint) => usages.includes(endpoint.usage)) ??
endpoints.find((endpoint) => methods.includes(endpoint.method)) ??
null
}
function methodCandidates(actionType) {
if (actionType === 'delete') return ['DELETE']
if (actionType === 'edit') return ['PUT', 'PATCH', 'GET']
if (actionType === 'print') return ['GET']
return ['POST', 'GET']
}
function missingEvidence(value, label) {
return value ? [] : [`缺少${label} evidence`]
}
function credentialPhrase(fields) {
const hasPassword = fields.some((field) => /密碼|password/i.test(field))
const hasAccount = fields.some((field) => /帳號|帳戶|account|user|email|信箱/i.test(field))
if (hasAccount && hasPassword) return '帳號與密碼'
return listPhrase(fields, '帳密')
}
function listPhrase(values, fallback) {
if (values.length === 0) return fallback
if (values.length === 1) return values[0]
return `${values.slice(0, -1).join('、')}${values.at(-1)}`
}
function pageName(feature) {
return /頁$/.test(feature) ? feature.replace(/頁$/, '') : feature
}
function cleanName(value) {
const text = String(value ?? '').replace(/\s+/g, ' ').trim()
return text || null
}
function unique(values) {
return [...new Set(values)]
}
function dedupeByKey(values, keyOf) {
const seen = new Set()
return values.filter((value) => {
const key = keyOf(value)
if (seen.has(key)) return false
seen.add(key)
return true
})
}