577 lines
23 KiB
JavaScript
577 lines
23 KiB
JavaScript
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { buildApiCatalog, matchApiEndpointCandidates, matchApiEndpoints } from '../src/lib/api-docs.js'
|
||
import { buildBddContract, renderBddContract } from '../src/lib/bdd.js'
|
||
import { buildMaintenanceContract } from '../src/lib/maintenance.js'
|
||
import { buildAppMap, buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../src/lib/html.js'
|
||
|
||
test('summarizeHtml extracts user-visible contract evidence', () => {
|
||
const summary = summarizeHtml(`
|
||
<title>Orders</title>
|
||
<main>
|
||
<h1>Orders</h1>
|
||
<form><label>Email</label><input name="email" required><button>Save</button></form>
|
||
</main>
|
||
`)
|
||
|
||
assert.equal(summary.title, 'Orders')
|
||
assert.deepEqual(summary.labels, ['Email'])
|
||
assert.deepEqual(summary.buttons, ['Save'])
|
||
assert.equal(summary.inputs[0].name, 'email')
|
||
assert.equal(summary.inputs[0].required, true)
|
||
})
|
||
|
||
test('extractRegions falls back to a single page region', () => {
|
||
const regions = extractRegions('<h1>Hello</h1><p>World</p>')
|
||
|
||
assert.equal(regions.length, 1)
|
||
assert.equal(regions[0].id, 'page-1')
|
||
})
|
||
|
||
test('inferRegionSpec maps forms to VForm', () => {
|
||
const [region] = extractRegions('<main><form><input name="q"><button>Search</button></form></main>')
|
||
const spec = inferRegionSpec(region)
|
||
|
||
assert.equal(spec.vuetifyComponent, 'VForm')
|
||
assert.deepEqual(spec.uiContract.primaryActions, ['Search'])
|
||
})
|
||
|
||
test('buildPageContract creates page-level UI contract', () => {
|
||
const html = `
|
||
<main>
|
||
<h1>Orders</h1>
|
||
<form><label>Email</label><input name="email" required><button>Search</button></form>
|
||
<table><thead><tr><th>Status</th></tr></thead><tbody><tr><td>Pending</td></tr></tbody></table>
|
||
</main>
|
||
`
|
||
const regions = extractRegions(html)
|
||
const domSummary = summarizeHtml(html)
|
||
const contract = buildPageContract({
|
||
page: 'orders.html',
|
||
source: '/prototype/orders.html',
|
||
html,
|
||
regions,
|
||
domSummary,
|
||
screenshotPath: '.ht/cache/prototype/orders/desktop-default.png'
|
||
})
|
||
|
||
assert.equal(contract.page, 'orders.html')
|
||
assert.equal(contract.forms[0].fields[0].required, true)
|
||
assert.deepEqual(contract.tables[0].headers, ['Status'])
|
||
assert.ok(contract.vuetifyComponents.includes('VTable'))
|
||
})
|
||
|
||
test('buildPageContract infers legacy table field labels and action semantics', () => {
|
||
const html = `
|
||
<main>
|
||
<form>
|
||
<table>
|
||
<tr><th>場地</th><td><select name="rom_id"><option>R001 大禮堂</option></select></td></tr>
|
||
<tr><th>查詢起日</th><td><input name="startDate" maxlength="7"></td></tr>
|
||
</table>
|
||
<input type="button" value="查詢">
|
||
</form>
|
||
<table><tr><th>申請單號</th><th>操作</th></tr><tr><td>A001</td><td><input type="button" value="刪除"></td></tr></table>
|
||
</main>
|
||
`
|
||
const contract = buildPageContract({
|
||
page: 'query-room.html',
|
||
source: '/prototype/query-room.html',
|
||
html,
|
||
regions: extractRegions(html),
|
||
domSummary: summarizeHtml(html),
|
||
screenshotPath: null
|
||
})
|
||
|
||
assert.equal(contract.forms[0].fields[0].label, '場地')
|
||
assert.deepEqual(contract.forms[0].fields[0].options, ['R001 大禮堂'])
|
||
assert.equal(contract.forms[0].fields[1].maxLength, '7')
|
||
assert.equal(contract.actions.find((action) => action.label === '查詢').actionType, 'search')
|
||
assert.equal(contract.actions.find((action) => action.label === '刪除').scope, 'rowAction')
|
||
assert.equal(contract.tables[0].role, 'searchTable')
|
||
assert.equal(contract.tables[1].role, 'resultTable')
|
||
})
|
||
|
||
test('buildPageContract keeps page actions out of row actions', () => {
|
||
const html = `
|
||
<main>
|
||
<input type="button" value="修改密碼" onclick="changePassword()">
|
||
<input type="button" value="列印" onclick="window.print()">
|
||
<table><tr><th>申請單號</th><th>操作</th></tr><tr><td>A001</td><td><input type="button" value="刪除"></td></tr></table>
|
||
</main>
|
||
`
|
||
const contract = buildPageContract({
|
||
page: 'applications-list.html',
|
||
source: '/prototype/applications-list.html',
|
||
html,
|
||
regions: extractRegions(html),
|
||
domSummary: summarizeHtml(html),
|
||
screenshotPath: null
|
||
})
|
||
|
||
assert.equal(contract.actions.find((action) => action.label === '修改密碼').scope, 'formAction')
|
||
assert.equal(contract.actions.find((action) => action.label === '列印').scope, 'formAction')
|
||
assert.equal(contract.actions.find((action) => action.label === '刪除').scope, 'rowAction')
|
||
})
|
||
|
||
test('validatePageContract reports evidence mismatches', () => {
|
||
const report = validatePageContract({
|
||
textSamples: ['Missing'],
|
||
actions: [{ label: 'Save' }],
|
||
forms: [],
|
||
vuetifyComponents: ['VContainer']
|
||
}, {
|
||
textSamples: ['Orders'],
|
||
buttons: ['Search'],
|
||
labels: []
|
||
})
|
||
|
||
assert.equal(report.requiresHumanReview, true)
|
||
assert.match(report.warnings.join('\n'), /Missing/)
|
||
assert.match(report.warnings.join('\n'), /Save/)
|
||
})
|
||
|
||
test('buildAppMap classifies auth, shell references, and feature pages', () => {
|
||
const prototypeDir = '/repo/prototype'
|
||
const specs = [
|
||
buildSpec('/repo/prototype/portal/login.html', '<main><input type="password" name="pwd"><button>登入</button></main>'),
|
||
buildSpec('/repo/prototype/portal/app-layout.html', '<main><button>隱藏選單</button><button>登 出</button></main>'),
|
||
buildSpec('/repo/prototype/venue/applications-list.html', '<main><h1>我的申請紀錄</h1><table><tr><th>申請單號</th></tr></table><button>查詢</button></main>')
|
||
]
|
||
|
||
const appMap = buildAppMap(specs, prototypeDir)
|
||
|
||
assert.equal(appMap.routes.find((route) => route.prototype === 'portal/login.html').kind, 'auth')
|
||
assert.equal(appMap.routes.find((route) => route.prototype === 'portal/login.html').layout, 'template-auth')
|
||
assert.equal(buildAppMap([
|
||
buildSpec('/repo/prototype/portal/forget-password.html', '<main><h1>忘記密碼</h1><input name="idno"><button>發送至信箱</button></main>')
|
||
], prototypeDir).routes[0].targetRole, 'forgot-password')
|
||
assert.equal(appMap.routes.find((route) => route.prototype === 'portal/app-layout.html').kind, 'legacy-shell-reference')
|
||
assert.equal(appMap.routes.find((route) => route.prototype === 'portal/app-layout.html').usePrototypeContent, false)
|
||
assert.equal(appMap.routes.find((route) => route.prototype === 'venue/applications-list.html').kind, 'feature-page')
|
||
assert.equal(appMap.routes.find((route) => route.prototype === 'venue/applications-list.html').layout, 'template-app')
|
||
assert.equal(appMap.modules.find((module) => module.name === 'venue').kind, 'feature-module')
|
||
})
|
||
|
||
test('buildAppMap enriches routes with prototype markdown guide entries', () => {
|
||
const prototypeDir = '/repo/prototype'
|
||
const guide = parsePrototypeGuide('venue.md', `
|
||
# Venue 雛型導覽
|
||
|
||
| 雛型檔 | 舊 JSP 來源 | 舊 PB NVO 來源 | 對應 Vue view(M9) |
|
||
| --- | --- | --- | --- |
|
||
| [\`query-room.html\`](venue/query-room.html) | \`zte_pro/zte451_02.jsp\` + \`zte451_02_1.jsp\` | \`n_zte451.of_zte451_02\` / \`of_zte451_02_1\` | \`RoomQueryView.vue\` |
|
||
|
||
## 舊系統整體流程
|
||
|
||
\`\`\`text
|
||
[使用者] 點 menu ZTE451
|
||
→ zte451_choose.jsp
|
||
① 場地查詢 → zte451_02.jsp → of_zte451_02 (場地下拉 + 日期)
|
||
↓ 「查詢」
|
||
zte451_02_1.jsp → of_zte451_02_1 (回 HTML:每日可借節次)
|
||
\`\`\`
|
||
`)
|
||
const appMap = buildAppMapWithGuides([
|
||
buildSpec('/repo/prototype/venue/query-room.html', '<main><h1>全校場地查詢</h1><button>查詢</button></main>')
|
||
], prototypeDir, [guide])
|
||
const route = appMap.routes[0]
|
||
|
||
assert.equal(appMap.guideSources[0].source, 'venue.md')
|
||
assert.equal(route.evidence.prototypeGuide, 'venue.md')
|
||
assert.equal(route.guide.legacyJsp, 'zte_pro/zte451_02.jsp + zte451_02_1.jsp')
|
||
assert.equal(route.guide.legacyPb, 'n_zte451.of_zte451_02 / of_zte451_02_1')
|
||
assert.equal(route.guide.targetView, 'RoomQueryView.vue')
|
||
assert.equal(appMap.guideSources[0].legacyFlowCount, 1)
|
||
assert.equal(appMap.legacyFlows[0].tasks[0], '場地查詢')
|
||
assert.equal(route.guide.flowRefs[0].tasks[0], '場地查詢')
|
||
assert.deepEqual(route.guide.flowRefs[0].matchedKeys, ['zte451_02.jsp', 'of_zte451_02', 'zte451_02_1.jsp', 'of_zte451_02_1'])
|
||
assert.equal(route.guide.flowRefs[0].nodes[0].nodeType, 'query')
|
||
})
|
||
|
||
test('parsePrototypeGuide attaches checklist items to guide entries', () => {
|
||
const guide = parsePrototypeGuide('venue.md', `
|
||
# Venue
|
||
|
||
| 雛型檔 | 舊 JSP 來源 | 對應 Vue view |
|
||
| --- | --- | --- |
|
||
| [\`apply-room.html\`](venue/apply-room.html) | \`legacy/apply.jsp\` | \`RoomApplyView.vue\` |
|
||
|
||
### apply-room.html vs legacy
|
||
|
||
- [ ] 活動名稱必填
|
||
- [ ] 使用節次:14 個 checkbox
|
||
`)
|
||
|
||
assert.deepEqual(guide.entries[0].checklist, ['活動名稱必填', '使用節次:14 個 checkbox'])
|
||
})
|
||
|
||
test('parsePrototypeGuide extracts legacy flow references for matched entries', () => {
|
||
const guide = parsePrototypeGuide('venue.md', `
|
||
# Venue
|
||
|
||
| 雛型檔 | 舊 JSP 來源 | 舊 PB NVO 來源 |
|
||
| --- | --- | --- |
|
||
| [\`apply-room.html\`](venue/apply-room.html) | \`zte_pro/zte450_01.jsp\` + \`zte450_med01.jsp\` | \`n_zte450.of_zte450_01\` / \`of_zte450_med01\` |
|
||
|
||
## 舊系統整體流程
|
||
|
||
\`\`\`text
|
||
[使用者] 點 menu ZTE450
|
||
→ zte450_choose.jsp
|
||
① 場地申請 → zte450_00.jsp?ls_stat=1 → of_zte450_00 (回 HTML 表單:身份+日期+stat)
|
||
↓ 「我要借用」
|
||
zte450_01.jsp → of_zte450_01 (回 HTML 表單:5 志願 + 14 節次)
|
||
↓ 存檔
|
||
zte450_ins.jsp → of_zte450_ins → 寫 s55_app_mst + s55_app_dtl
|
||
\`\`\`
|
||
`)
|
||
|
||
assert.equal(guide.legacyFlows.length, 1)
|
||
assert.equal(guide.entries[0].flowRefs[0].tasks[0], '場地申請')
|
||
assert.ok(guide.entries[0].flowRefs[0].nodes.some((node) => node.nodeType === 'precondition'))
|
||
assert.ok(guide.entries[0].flowRefs[0].nodes.some((node) => node.jsp.includes('zte450_01.jsp')))
|
||
})
|
||
|
||
test('buildApiCatalog parses generic markdown API docs and matches routes', () => {
|
||
const catalog = buildApiCatalog([
|
||
{
|
||
source: 'apps/backend/API.md',
|
||
markdown: `
|
||
## Orders Module
|
||
|
||
| 方法 | 路徑 | 說明 | 授權 |
|
||
| --- | --- | --- | --- |
|
||
| GET | \`/api/v1/orders\` | 查詢訂單 | Authorize |
|
||
`
|
||
},
|
||
{
|
||
source: 'apps/backend/API_Manual.md',
|
||
markdown: `
|
||
## 5. Orders API
|
||
|
||
### 5.1 新增訂單
|
||
|
||
\`\`\`text
|
||
POST /api/v1/orders
|
||
\`\`\`
|
||
|
||
Request:
|
||
|
||
\`\`\`json
|
||
{ "orderName": "Demo", "items": [{ "sku": "A001", "qty": 1 }] }
|
||
\`\`\`
|
||
|
||
Response:
|
||
|
||
\`\`\`json
|
||
{ "orderNo": "O001" }
|
||
\`\`\`
|
||
|
||
欄位規則:
|
||
|
||
| 欄位 | 規則 |
|
||
| --- | --- |
|
||
| \`orderName\` | 必填 |
|
||
`
|
||
}
|
||
])
|
||
|
||
assert.ok(catalog.endpoints.some((endpoint) => endpoint.id === 'POST /api/v1/orders'))
|
||
assert.equal(catalog.fieldRules[0].field, 'orderName')
|
||
const matches = matchApiEndpoints({
|
||
prototype: 'orders/apply.html',
|
||
page: 'apply.html',
|
||
module: 'orders',
|
||
title: '訂單申請',
|
||
guide: { targetView: 'OrderApplyView.vue' },
|
||
evidence: { actions: ['存檔'], textSamples: ['訂單'] }
|
||
}, catalog)
|
||
assert.equal(matches[0].path, '/api/v1/orders')
|
||
})
|
||
|
||
test('matchApiEndpointCandidates narrows chooser and print pages', () => {
|
||
const catalog = buildApiCatalog([{
|
||
source: 'apps/backend/API.md',
|
||
markdown: `
|
||
| 方法 | 路徑 | 說明 |
|
||
| --- | --- | --- |
|
||
| GET | \`/service/api/v1/venue/applications/rooms\` | 查詢我的場地申請 |
|
||
| POST | \`/service/api/v1/venue/applications/rooms\` | 新增場地借用申請 |
|
||
| GET | \`/service/api/v1/venue/applications/rooms/{appNo}/print-view\` | 取得場地申請列印資料 |
|
||
| GET | \`/service/api/v1/venue/rooms\` | 取得場地清單 |
|
||
`
|
||
}])
|
||
|
||
const chooser = matchApiEndpointCandidates({
|
||
prototype: 'venue/apply-choose.html',
|
||
page: 'apply-choose.html',
|
||
module: 'venue',
|
||
evidence: { actions: ['場地申請'], textSamples: ['場地設備借用申請'] }
|
||
}, catalog)
|
||
const print = matchApiEndpointCandidates({
|
||
prototype: 'venue/apply-room-print.html',
|
||
page: 'apply-room-print.html',
|
||
module: 'venue',
|
||
evidence: { actions: ['列印'], textSamples: ['場地申請表列印'] }
|
||
}, catalog)
|
||
|
||
assert.deepEqual(chooser.matches.map((endpoint) => endpoint.usage), ['lookup'])
|
||
assert.deepEqual(print.matches.map((endpoint) => endpoint.usage), ['print'])
|
||
})
|
||
|
||
test('buildMaintenanceContract recommends generic templates from evidence', () => {
|
||
const spec = buildSpec('/repo/prototype/orders/apply.html', `
|
||
<main>
|
||
<h1>訂單申請</h1>
|
||
<form>
|
||
<table>
|
||
<tr><th>訂單名稱</th><td><input name="orderName"></td></tr>
|
||
<tr><th>明細</th><td><select name="items"><option>A001</option></select></td></tr>
|
||
</table>
|
||
<input type="button" value="存檔">
|
||
</form>
|
||
</main>
|
||
`)
|
||
const route = {
|
||
kind: 'feature-page',
|
||
prototype: 'orders/apply.html',
|
||
page: 'apply.html',
|
||
guide: { targetView: 'OrderApplyView.vue' }
|
||
}
|
||
const contract = buildMaintenanceContract({
|
||
route,
|
||
spec,
|
||
apiMatches: [{ method: 'POST', path: '/api/v1/orders', description: '新增訂單' }]
|
||
})
|
||
|
||
assert.equal(contract.pageKind, 'application')
|
||
assert.equal(contract.recommendedTemplate, 'master-detail-c')
|
||
assert.equal(contract.dataModel.primaryEntity, 'OrderApply')
|
||
})
|
||
|
||
test('buildMaintenanceContract extracts business rules and row action status rules', () => {
|
||
const spec = buildSpec('/repo/prototype/venue/applications-list.html', `
|
||
<main>
|
||
<table>
|
||
<tr><th>申請單號</th><th>狀態</th><th>操作</th></tr>
|
||
<tr><td>A001</td><td>審核中(Z)</td><td><input type="button" value="修改"><input type="button" value="刪除"><input type="button" value="列印"></td></tr>
|
||
<tr><td>A002</td><td>已核准(Y)</td><td>aprv≠'Z' 修改/刪除 disabled</td></tr>
|
||
</table>
|
||
</main>
|
||
`)
|
||
spec.prototypeGuide = {
|
||
checklist: [
|
||
"操作按鈕:修改 / 刪除 / 列印(aprv_yn!='Z' 時前兩者 disabled,舊系統 UI 用顯示與否)",
|
||
'設備明細(最多 15 項):每行設備下拉 + 數量 + 移除',
|
||
'截止日(asOfDate;不填用今天)',
|
||
'JS 驗證:home_phone 不可空白、IsNumeric 檢查、email 含 @'
|
||
]
|
||
}
|
||
const contract = buildMaintenanceContract({
|
||
route: {
|
||
kind: 'feature-page',
|
||
prototype: 'venue/applications-list.html',
|
||
page: 'applications-list.html',
|
||
guide: { targetView: 'ApplicationsListView.vue' }
|
||
},
|
||
spec,
|
||
apiMatches: []
|
||
})
|
||
|
||
assert.equal(contract.rowActions.find((action) => action.actionType === 'edit').enabledWhen, "aprvYn === 'Z'")
|
||
assert.equal(contract.rowActions.find((action) => action.actionType === 'delete').enabledWhen, "aprvYn === 'Z'")
|
||
assert.equal(contract.rowActions.find((action) => action.actionType === 'print').enabledWhen, null)
|
||
assert.equal(contract.businessRules.collectionLimits[0].max, 15)
|
||
assert.ok(contract.businessRules.dataSources.some((rule) => rule.ruleType === 'default-today'))
|
||
assert.ok(contract.businessRules.validationRules.some((rule) => rule.ruleType === 'email'))
|
||
})
|
||
|
||
test('buildMaintenanceContract ignores non-vue guide text for primary entity', () => {
|
||
const spec = buildSpec('/repo/prototype/venue/apply-room-print.html', `
|
||
<main>
|
||
<h1>場地申請表列印</h1>
|
||
<input type="button" value="列印">
|
||
</main>
|
||
`)
|
||
const contract = buildMaintenanceContract({
|
||
route: {
|
||
kind: 'feature-page',
|
||
prototype: 'venue/apply-room-print.html',
|
||
page: 'apply-room-print.html',
|
||
guide: { targetView: 'M9 列印頁尚未實作,列出供下批補做' }
|
||
},
|
||
spec,
|
||
apiMatches: [{ method: 'GET', path: '/service/api/v1/venue/applications/rooms/{appNo}/print-view', description: '取得場地申請列印資料' }]
|
||
})
|
||
|
||
assert.equal(contract.pageKind, 'print')
|
||
assert.equal(contract.dataModel.primaryEntity, 'PrintView')
|
||
assert.deepEqual(contract.capabilities, ['print'])
|
||
})
|
||
|
||
test('buildBddContract creates traceable auth scenarios from page evidence', () => {
|
||
const spec = buildSpec('/repo/prototype/portal/login.html', `
|
||
<main>
|
||
<h1>使用者登入</h1>
|
||
<form>
|
||
<label>帳號</label><input name="account" required>
|
||
<label>密碼</label><input name="password" type="password" required>
|
||
<button>登入</button>
|
||
</form>
|
||
</main>
|
||
`)
|
||
spec.maintenanceContract = {
|
||
pageKind: 'login',
|
||
capabilities: ['custom'],
|
||
dataModel: { primaryEntity: 'Login' },
|
||
businessRules: { validationRules: [{ ruleType: 'required', text: '帳號與密碼必填' }] },
|
||
rowActions: []
|
||
}
|
||
spec.prototypeGuide = {
|
||
description: '使用者登入',
|
||
checklist: ['登入失敗時顯示錯誤訊息'],
|
||
flowRefs: [{ title: '登入流程', nodes: [{ nodeType: 'submit', action: '登入' }] }]
|
||
}
|
||
spec.apiContract = {
|
||
endpoints: [{ method: 'POST', path: '/api/auth/login', usage: 'submit', description: '使用者登入' }]
|
||
}
|
||
|
||
const contract = buildBddContract(spec)
|
||
const markdown = renderBddContract(spec, contract)
|
||
|
||
assert.equal(contract.feature, '使用者登入')
|
||
assert.equal(contract.scenarios[0].name, '使用者輸入正確帳密登入成功')
|
||
assert.deepEqual(contract.scenarios[0].given, ['使用者已在使用者登入頁'])
|
||
assert.deepEqual(contract.scenarios[0].when, ['使用者輸入正確的帳號與密碼並執行「登入」'])
|
||
assert.ok(contract.scenarios[0].then.includes('系統應送出 POST /api/auth/login'))
|
||
assert.deepEqual(contract.scenarios[0].evidence.fields, ['帳號', '密碼'])
|
||
assert.deepEqual(contract.scenarios[0].evidence.checklist, ['登入失敗時顯示錯誤訊息'])
|
||
assert.equal(contract.requiresHumanReview, true)
|
||
assert.match(markdown, /Feature: 使用者登入/)
|
||
assert.match(markdown, /Scenario: 使用者輸入正確帳密登入成功/)
|
||
assert.match(markdown, /Then 系統應送出 POST \/api\/auth\/login/)
|
||
assert.match(markdown, /## Evidence/)
|
||
})
|
||
|
||
test('buildBddContract creates query and row action scenarios with review warnings', () => {
|
||
const spec = buildSpec('/repo/prototype/venue/applications-list.html', `
|
||
<main>
|
||
<h1>我的申請紀錄</h1>
|
||
<form><label>狀態</label><select name="status"><option>審核中</option></select><button>查詢</button></form>
|
||
<table>
|
||
<tr><th>申請單號</th><th>狀態</th><th>操作</th></tr>
|
||
<tr><td>A001</td><td>審核中</td><td><input type="button" value="修改"><input type="button" value="刪除"></td></tr>
|
||
</table>
|
||
</main>
|
||
`)
|
||
spec.maintenanceContract = {
|
||
pageKind: 'maintenance',
|
||
capabilities: ['search', 'edit', 'delete'],
|
||
dataModel: { primaryEntity: 'ApplicationsList' },
|
||
businessRules: { validationRules: [], statusRules: [{ ruleType: 'enabled-when-status', field: 'aprvYn', value: 'Z', text: 'aprvYn=Z 才可修改刪除' }] },
|
||
rowActions: [
|
||
{ label: '修改', actionType: 'edit', enabledWhen: "aprvYn === 'Z'" },
|
||
{ label: '刪除', actionType: 'delete', enabledWhen: "aprvYn === 'Z'" }
|
||
]
|
||
}
|
||
spec.apiContract = {
|
||
endpoints: [
|
||
{ method: 'GET', path: '/api/applications', usage: 'query', description: '查詢申請紀錄' },
|
||
{ method: 'DELETE', path: '/api/applications/{id}', usage: 'delete', description: '刪除申請' }
|
||
]
|
||
}
|
||
|
||
const contract = buildBddContract(spec)
|
||
|
||
assert.equal(contract.scenarios[0].type, 'query')
|
||
assert.deepEqual(contract.scenarios[0].then, ['系統應送出 GET /api/applications', '顯示符合條件的查詢結果'])
|
||
assert.ok(contract.scenarios.some((scenario) => scenario.name === '符合狀態條件時可以修改資料列'))
|
||
assert.ok(contract.scenarios.some((scenario) => scenario.name === '符合狀態條件時可以刪除資料列'))
|
||
assert.ok(contract.warnings.some((warning) => warning.includes('Then')))
|
||
})
|
||
|
||
test('buildBddContract reports scenario candidates and uncovered evidence', () => {
|
||
const spec = buildSpec('/repo/prototype/portal/login.html', `
|
||
<main>
|
||
<h1>使用者登入</h1>
|
||
<form>
|
||
<label>帳號</label><input name="account" required>
|
||
<label>密碼</label><input name="password" type="password" required>
|
||
<button>登入</button>
|
||
<button>忘記密碼</button>
|
||
</form>
|
||
</main>
|
||
`)
|
||
spec.maintenanceContract = {
|
||
pageKind: 'login',
|
||
capabilities: ['custom'],
|
||
dataModel: { primaryEntity: 'Login' },
|
||
businessRules: { validationRules: [{ ruleType: 'required', text: '帳號與密碼必填' }] },
|
||
rowActions: []
|
||
}
|
||
spec.prototypeGuide = {
|
||
description: '使用者登入',
|
||
checklist: ['登入失敗時顯示錯誤訊息', '連續錯誤三次鎖定帳號', '支援外部 SSO 導向'],
|
||
flowRefs: [{ title: '登入失敗流程', nodes: [{ nodeType: 'error', action: '顯示錯誤訊息' }] }]
|
||
}
|
||
spec.apiContract = {
|
||
endpoints: [{ method: 'POST', path: '/api/auth/login', usage: 'submit', description: '使用者登入' }],
|
||
errorHandling: { formats: [{ status: 401, description: '帳號或密碼錯誤' }] }
|
||
}
|
||
|
||
const contract = buildBddContract(spec)
|
||
const markdown = renderBddContract(spec, contract)
|
||
|
||
assert.ok(contract.candidateScenarios.some((candidate) => candidate.type === 'error-path'))
|
||
assert.ok(contract.candidateScenarios.some((candidate) => candidate.source === 'prototypeGuide.checklist' && candidate.evidenceText === '登入失敗時顯示錯誤訊息'))
|
||
assert.ok(contract.candidateScenarios.some((candidate) => candidate.source === 'api-error'))
|
||
assert.ok(contract.uncoveredEvidence.some((item) => item.source === 'prototypeGuide.checklist' && item.text === '支援外部 SSO 導向'))
|
||
assert.ok(!contract.uncoveredEvidence.some((item) => item.text === '登入失敗時顯示錯誤訊息'))
|
||
assert.ok(contract.uncoveredEvidence.some((item) => item.source === 'pageContract.actions' && item.text === '忘記密碼'))
|
||
assert.equal(contract.requiresHumanReview, true)
|
||
assert.match(markdown, /## Candidate Scenarios/)
|
||
assert.match(markdown, /登入失敗時顯示錯誤訊息/)
|
||
assert.match(markdown, /## Uncovered Evidence/)
|
||
assert.match(markdown, /支援外部 SSO 導向/)
|
||
})
|
||
|
||
test('buildPageContract prefers meaningful rendered labels over fallback control names', () => {
|
||
const contract = buildPageContract({
|
||
page: 'dynamic-form.html',
|
||
source: '/repo/prototype/dynamic-form.html',
|
||
html: '<main><input id="email"></main>',
|
||
regions: extractRegions('<main><input id="email"></main>'),
|
||
domSummary: {
|
||
title: null,
|
||
headings: [],
|
||
buttons: [],
|
||
labels: ['Email'],
|
||
inputs: [{ tag: 'input', type: 'text', name: 'email', label: 'Email', required: true }],
|
||
textSamples: ['Email']
|
||
},
|
||
screenshotPath: null
|
||
})
|
||
|
||
assert.equal(contract.forms[0].fields[0].label, 'Email')
|
||
assert.equal(contract.forms[0].fields[0].required, true)
|
||
})
|
||
|
||
function buildSpec(source, html) {
|
||
const regions = extractRegions(html)
|
||
const domSummary = summarizeHtml(html)
|
||
const page = source.split('/').at(-1)
|
||
return {
|
||
source,
|
||
page,
|
||
pageContract: buildPageContract({
|
||
page,
|
||
source,
|
||
html,
|
||
regions,
|
||
domSummary,
|
||
screenshotPath: `.ht/cache/prototype/${page.replace(/\.html$/, '')}/desktop-default.png`
|
||
})
|
||
}
|
||
}
|