Files
html-transform/test/html.test.js
T
skytek_xinliang c8ff825722 feat: derive flow refs and refine maintenance contracts
Parse legacy flow blocks from prototype guides into flowRefs so prompts can rely on extracted evidence instead of hardcoded JSP flow knowledge.

Refine API endpoint matching for special route roles and prototype types, and expand maintenance contracts with row action conditions and checklist-derived business rules. Update README docs to reflect the new contract fields and app-map output.feat: derive flow refs and refine maintenance contracts

Parse legacy flow blocks from prototype guides into flowRefs so prompts can rely on extracted evidence instead of hardcoded JSP flow knowledge.

Refine API endpoint matching for special route roles and prototype types, and expand maintenance contracts with row action conditions and checklist-derived business rules. Update README docs to reflect the new contract fields and app-map output.
2026-05-21 16:05:39 +08:00

429 lines
16 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 { 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 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(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'])
})
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`
})
}
}