Files
html-transform/test/html.test.js
T
2026-06-08 11:53:46 +08:00

573 lines
23 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.
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 only keeps AI-implementable auth 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'), undefined)
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')
})
test('buildAppMap enriches routes with prototype markdown guide entries', () => {
const prototypeDir = '/repo/prototype'
const guide = parsePrototypeGuide('venue.md', `
# Venue 雛型導覽
| 雛型檔 | 舊 JSP 來源 | 舊 PB NVO 來源 | 對應 Vue viewM9 |
| --- | --- | --- | --- |
| [\`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(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(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 === 'form'))
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'))
const ordersEndpoint = catalog.endpoints.find((endpoint) => endpoint.id === 'POST /api/v1/orders')
assert.equal(ordersEndpoint.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`
})
}
}