430 lines
17 KiB
JavaScript
430 lines
17 KiB
JavaScript
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
|
||
})
|
||
}
|