feat: BDD Contract
This commit is contained in:
+429
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user