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 }) }