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(` Orders

Orders

`) 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('

Hello

World

') assert.equal(regions.length, 1) assert.equal(regions[0].id, 'page-1') }) test('inferRegionSpec maps forms to VForm', () => { const [region] = extractRegions('
') const spec = inferRegionSpec(region) assert.equal(spec.vuetifyComponent, 'VForm') assert.deepEqual(spec.uiContract.primaryActions, ['Search']) }) test('buildPageContract creates page-level UI contract', () => { const html = `

Orders

Status
Pending
` 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 = `
場地
查詢起日
申請單號操作
A001
` 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 = `
申請單號操作
A001
` 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', '
'), buildSpec('/repo/prototype/portal/app-layout.html', '
'), buildSpec('/repo/prototype/venue/applications-list.html', '

我的申請紀錄

申請單號
') ] 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', '

忘記密碼

') ], 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', '

全校場地查詢

') ], 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', `

訂單申請

訂單名稱
明細
`) 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', `
申請單號狀態操作
A001審核中(Z)
A002已核准(Y)aprv≠'Z' 修改/刪除 disabled
`) 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', `

場地申請表列印

`) 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', `

使用者登入

`) 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', `

我的申請紀錄

申請單號狀態操作
A001審核中
`) 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', `

使用者登入

`) spec.maintenanceContract = { pageKind: 'login', capabilities: ['custom'], dataModel: { primaryEntity: 'Login' }, businessRules: { validationRules: [{ ruleType: 'required', text: '帳號與密碼必填' }] }, rowActions: [] } spec.prototypeGuide = { description: '使用者登入', checklist: ['登入失敗時顯示錯誤訊息', '連續錯誤三次鎖定帳號'], 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 === 'api-error')) assert.ok(contract.uncoveredEvidence.some((item) => item.source === 'prototypeGuide.checklist' && 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, /連續錯誤三次鎖定帳號/) }) 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` }) } }