Files
html-transform/src/lib/bdd.js
T
2026-05-24 10:38:17 +08:00

430 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
})
}