feat: BDD Contract
This commit is contained in:
@@ -194,6 +194,7 @@ node packages/html-transform/src/cli.js scan
|
|||||||
.ht/spec/{page}.spec.json
|
.ht/spec/{page}.spec.json
|
||||||
.ht/spec/{page}.validation.json
|
.ht/spec/{page}.validation.json
|
||||||
.ht/spec/{page}.ui-contract.md
|
.ht/spec/{page}.ui-contract.md
|
||||||
|
.ht/spec/{page}.bdd.md
|
||||||
```
|
```
|
||||||
|
|
||||||
`.ht/api-catalog.json` 是跨頁面的 backend API catalog,來源是 `backendDocs` 目錄下的 markdown 文件。它會包含:
|
`.ht/api-catalog.json` 是跨頁面的 backend API catalog,來源是 `backendDocs` 目錄下的 markdown 文件。它會包含:
|
||||||
@@ -210,8 +211,11 @@ node packages/html-transform/src/cli.js scan
|
|||||||
- `prototypeGuide`:該 prototype 對應的人工 guide、舊 JSP/PB、target view 與 checklist。
|
- `prototypeGuide`:該 prototype 對應的人工 guide、舊 JSP/PB、target view 與 checklist。
|
||||||
- `prototypeGuide.flowRefs`:從 guide 的 legacy flow code block 比對出的相關流程節點,包含 menu、task、nodeType、JSP、PB、動作與原始流程行。
|
- `prototypeGuide.flowRefs`:從 guide 的 legacy flow code block 比對出的相關流程節點,包含 menu、task、nodeType、JSP、PB、動作與原始流程行。
|
||||||
- `maintenanceContract`:頁面型態、capabilities、data model、row actions、business rules 與 warnings。
|
- `maintenanceContract`:頁面型態、capabilities、data model、row actions、business rules 與 warnings。
|
||||||
|
- `bddContract`:以頁面 evidence 產生的 Gherkin 草稿、scenario evidence trace 與人工 review warnings。
|
||||||
- `regions`:目前仍保留,目的是相容既有資料形狀;不要把它視為 Stage 2 decomposition。
|
- `regions`:目前仍保留,目的是相容既有資料形狀;不要把它視為 Stage 2 decomposition。
|
||||||
|
|
||||||
|
`.bdd.md` 是 BDD 起始模版,會依 `pageKind` 產生 auth、query、maintenance、application、print 或 generic scenario。每個 scenario 會保留欄位、actions、tables、API endpoints、prototype checklist 與 legacy flow refs,供人工 review 後再轉成可執行測試。若 scan 看見錯誤流程、必填驗證、狀態限制、API error format 或尚未被 scenario 使用的 checklist/action/API,會列入 `candidateScenarios` 或 `uncoveredEvidence`,避免 evidence 被靜默漏掉。
|
||||||
|
|
||||||
`.ht/app-map.json` 是跨頁面的應用結構推論。通用 prompt 應先讀它,再決定每個 prototype 是 `auth`、`legacy-shell-reference`、`feature-page` 或其他角色。MVP 固定策略是 template layout/style 優先,prototype 只提供內容與功能證據。
|
`.ht/app-map.json` 是跨頁面的應用結構推論。通用 prompt 應先讀它,再決定每個 prototype 是 `auth`、`legacy-shell-reference`、`feature-page` 或其他角色。MVP 固定策略是 template layout/style 優先,prototype 只提供內容與功能證據。
|
||||||
|
|
||||||
route 也會包含 implementation hints,例如:
|
route 也會包含 implementation hints,例如:
|
||||||
|
|||||||
Generated
+471
@@ -11,19 +11,235 @@ importers:
|
|||||||
'@playwright/cli':
|
'@playwright/cli':
|
||||||
specifier: ^0.1.8
|
specifier: ^0.1.8
|
||||||
version: 0.1.11
|
version: 0.1.11
|
||||||
|
vite:
|
||||||
|
specifier: ^8.0.10
|
||||||
|
version: 8.0.14
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
'@emnapi/core@1.10.0':
|
||||||
|
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.10.0':
|
||||||
|
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||||
|
|
||||||
|
'@emnapi/wasi-threads@1.2.1':
|
||||||
|
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||||
|
|
||||||
|
'@napi-rs/wasm-runtime@1.1.4':
|
||||||
|
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
||||||
|
peerDependencies:
|
||||||
|
'@emnapi/core': ^1.7.1
|
||||||
|
'@emnapi/runtime': ^1.7.1
|
||||||
|
|
||||||
|
'@oxc-project/types@0.132.0':
|
||||||
|
resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==}
|
||||||
|
|
||||||
'@playwright/cli@0.1.11':
|
'@playwright/cli@0.1.11':
|
||||||
resolution: {integrity: sha512-nz6xPChoijBsxZTSukJFBCCwLozsp7uwBLYrlCrmRU6MXc2a+mpaaLApqBxMfVFjgbP4pYY/3uQ/NnpD+aicxw==}
|
resolution: {integrity: sha512-nz6xPChoijBsxZTSukJFBCCwLozsp7uwBLYrlCrmRU6MXc2a+mpaaLApqBxMfVFjgbP4pYY/3uQ/NnpD+aicxw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@rolldown/binding-android-arm64@1.0.2':
|
||||||
|
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rolldown/binding-darwin-arm64@1.0.2':
|
||||||
|
resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rolldown/binding-darwin-x64@1.0.2':
|
||||||
|
resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rolldown/binding-freebsd-x64@1.0.2':
|
||||||
|
resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
|
||||||
|
resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm64-gnu@1.0.2':
|
||||||
|
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm64-musl@1.0.2':
|
||||||
|
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
|
||||||
|
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-s390x-gnu@1.0.2':
|
||||||
|
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-x64-gnu@1.0.2':
|
||||||
|
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-x64-musl@1.0.2':
|
||||||
|
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-openharmony-arm64@1.0.2':
|
||||||
|
resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@rolldown/binding-wasm32-wasi@1.0.2':
|
||||||
|
resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@rolldown/binding-win32-arm64-msvc@1.0.2':
|
||||||
|
resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rolldown/binding-win32-x64-msvc@1.0.2':
|
||||||
|
resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rolldown/pluginutils@1.0.1':
|
||||||
|
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||||
|
|
||||||
|
'@tybys/wasm-util@0.10.2':
|
||||||
|
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
fdir@6.5.0:
|
||||||
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
picomatch: ^3 || ^4
|
||||||
|
peerDependenciesMeta:
|
||||||
|
picomatch:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.2:
|
fsevents@2.3.2:
|
||||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lightningcss-android-arm64@1.32.0:
|
||||||
|
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
lightningcss-darwin-arm64@1.32.0:
|
||||||
|
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lightningcss-darwin-x64@1.32.0:
|
||||||
|
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lightningcss-freebsd-x64@1.32.0:
|
||||||
|
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
lightningcss-linux-arm-gnueabihf@1.32.0:
|
||||||
|
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-gnu@1.32.0:
|
||||||
|
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-musl@1.32.0:
|
||||||
|
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-x64-gnu@1.32.0:
|
||||||
|
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-x64-musl@1.32.0:
|
||||||
|
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-win32-arm64-msvc@1.32.0:
|
||||||
|
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
lightningcss-win32-x64-msvc@1.32.0:
|
||||||
|
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
lightningcss@1.32.0:
|
||||||
|
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
nanoid@3.3.12:
|
||||||
|
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
|
||||||
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
picocolors@1.1.1:
|
||||||
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
picomatch@4.0.4:
|
||||||
|
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
playwright-core@1.60.0-alpha-1777669338000:
|
playwright-core@1.60.0-alpha-1777669338000:
|
||||||
resolution: {integrity: sha512-CbBF+YtjGGK2mWKdH6iiSNIB9v9Sq8owcUrTy1cw6FgMZavspM6ZSpidQQgQXz/1scQFrM110jrtJqTYwnjbeA==}
|
resolution: {integrity: sha512-CbBF+YtjGGK2mWKdH6iiSNIB9v9Sq8owcUrTy1cw6FgMZavspM6ZSpidQQgQXz/1scQFrM110jrtJqTYwnjbeA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -34,15 +250,223 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
postcss@8.5.15:
|
||||||
|
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||||
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
rolldown@1.0.2:
|
||||||
|
resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
source-map-js@1.2.1:
|
||||||
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
tinyglobby@0.2.16:
|
||||||
|
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
|
vite@8.0.14:
|
||||||
|
resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': ^20.19.0 || >=22.12.0
|
||||||
|
'@vitejs/devtools': ^0.1.18
|
||||||
|
esbuild: ^0.27.0 || ^0.28.0
|
||||||
|
jiti: '>=1.21.0'
|
||||||
|
less: ^4.0.0
|
||||||
|
sass: ^1.70.0
|
||||||
|
sass-embedded: ^1.70.0
|
||||||
|
stylus: '>=0.54.8'
|
||||||
|
sugarss: ^5.0.0
|
||||||
|
terser: ^5.16.0
|
||||||
|
tsx: ^4.8.1
|
||||||
|
yaml: ^2.4.2
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitejs/devtools':
|
||||||
|
optional: true
|
||||||
|
esbuild:
|
||||||
|
optional: true
|
||||||
|
jiti:
|
||||||
|
optional: true
|
||||||
|
less:
|
||||||
|
optional: true
|
||||||
|
sass:
|
||||||
|
optional: true
|
||||||
|
sass-embedded:
|
||||||
|
optional: true
|
||||||
|
stylus:
|
||||||
|
optional: true
|
||||||
|
sugarss:
|
||||||
|
optional: true
|
||||||
|
terser:
|
||||||
|
optional: true
|
||||||
|
tsx:
|
||||||
|
optional: true
|
||||||
|
yaml:
|
||||||
|
optional: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
'@emnapi/core@1.10.0':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/wasi-threads': 1.2.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.10.0':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/wasi-threads@1.2.1':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/core': 1.10.0
|
||||||
|
'@emnapi/runtime': 1.10.0
|
||||||
|
'@tybys/wasm-util': 0.10.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-project/types@0.132.0': {}
|
||||||
|
|
||||||
'@playwright/cli@0.1.11':
|
'@playwright/cli@0.1.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.60.0-alpha-1777669338000
|
playwright: 1.60.0-alpha-1777669338000
|
||||||
|
|
||||||
|
'@rolldown/binding-android-arm64@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-darwin-arm64@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-darwin-x64@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-freebsd-x64@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm64-gnu@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm64-musl@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-s390x-gnu@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-x64-gnu@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-x64-musl@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-openharmony-arm64@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-wasm32-wasi@1.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/core': 1.10.0
|
||||||
|
'@emnapi/runtime': 1.10.0
|
||||||
|
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-win32-arm64-msvc@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-win32-x64-msvc@1.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/pluginutils@1.0.1': {}
|
||||||
|
|
||||||
|
'@tybys/wasm-util@0.10.2':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
|
fdir@6.5.0(picomatch@4.0.4):
|
||||||
|
optionalDependencies:
|
||||||
|
picomatch: 4.0.4
|
||||||
|
|
||||||
fsevents@2.3.2:
|
fsevents@2.3.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-android-arm64@1.32.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-darwin-arm64@1.32.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-darwin-x64@1.32.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-freebsd-x64@1.32.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm-gnueabihf@1.32.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-gnu@1.32.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-musl@1.32.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-x64-gnu@1.32.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-x64-musl@1.32.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-win32-arm64-msvc@1.32.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-win32-x64-msvc@1.32.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss@1.32.0:
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
optionalDependencies:
|
||||||
|
lightningcss-android-arm64: 1.32.0
|
||||||
|
lightningcss-darwin-arm64: 1.32.0
|
||||||
|
lightningcss-darwin-x64: 1.32.0
|
||||||
|
lightningcss-freebsd-x64: 1.32.0
|
||||||
|
lightningcss-linux-arm-gnueabihf: 1.32.0
|
||||||
|
lightningcss-linux-arm64-gnu: 1.32.0
|
||||||
|
lightningcss-linux-arm64-musl: 1.32.0
|
||||||
|
lightningcss-linux-x64-gnu: 1.32.0
|
||||||
|
lightningcss-linux-x64-musl: 1.32.0
|
||||||
|
lightningcss-win32-arm64-msvc: 1.32.0
|
||||||
|
lightningcss-win32-x64-msvc: 1.32.0
|
||||||
|
|
||||||
|
nanoid@3.3.12: {}
|
||||||
|
|
||||||
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
picomatch@4.0.4: {}
|
||||||
|
|
||||||
playwright-core@1.60.0-alpha-1777669338000: {}
|
playwright-core@1.60.0-alpha-1777669338000: {}
|
||||||
|
|
||||||
playwright@1.60.0-alpha-1777669338000:
|
playwright@1.60.0-alpha-1777669338000:
|
||||||
@@ -50,3 +474,50 @@ snapshots:
|
|||||||
playwright-core: 1.60.0-alpha-1777669338000
|
playwright-core: 1.60.0-alpha-1777669338000
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
|
|
||||||
|
postcss@8.5.15:
|
||||||
|
dependencies:
|
||||||
|
nanoid: 3.3.12
|
||||||
|
picocolors: 1.1.1
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
rolldown@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
'@oxc-project/types': 0.132.0
|
||||||
|
'@rolldown/pluginutils': 1.0.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@rolldown/binding-android-arm64': 1.0.2
|
||||||
|
'@rolldown/binding-darwin-arm64': 1.0.2
|
||||||
|
'@rolldown/binding-darwin-x64': 1.0.2
|
||||||
|
'@rolldown/binding-freebsd-x64': 1.0.2
|
||||||
|
'@rolldown/binding-linux-arm-gnueabihf': 1.0.2
|
||||||
|
'@rolldown/binding-linux-arm64-gnu': 1.0.2
|
||||||
|
'@rolldown/binding-linux-arm64-musl': 1.0.2
|
||||||
|
'@rolldown/binding-linux-ppc64-gnu': 1.0.2
|
||||||
|
'@rolldown/binding-linux-s390x-gnu': 1.0.2
|
||||||
|
'@rolldown/binding-linux-x64-gnu': 1.0.2
|
||||||
|
'@rolldown/binding-linux-x64-musl': 1.0.2
|
||||||
|
'@rolldown/binding-openharmony-arm64': 1.0.2
|
||||||
|
'@rolldown/binding-wasm32-wasi': 1.0.2
|
||||||
|
'@rolldown/binding-win32-arm64-msvc': 1.0.2
|
||||||
|
'@rolldown/binding-win32-x64-msvc': 1.0.2
|
||||||
|
|
||||||
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
tinyglobby@0.2.16:
|
||||||
|
dependencies:
|
||||||
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
vite@8.0.14:
|
||||||
|
dependencies:
|
||||||
|
lightningcss: 1.32.0
|
||||||
|
picomatch: 4.0.4
|
||||||
|
postcss: 8.5.15
|
||||||
|
rolldown: 1.0.2
|
||||||
|
tinyglobby: 0.2.16
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|||||||
+429
@@ -0,0 +1,429 @@
|
|||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
+13
-1
@@ -4,6 +4,7 @@ import { basename, join, relative } from 'node:path'
|
|||||||
import { loadConfig } from '../lib/config.js'
|
import { loadConfig } from '../lib/config.js'
|
||||||
import { artifactPath, ensureDir, exists, listFiles, readJson, sha256File, writeJson } from '../lib/files.js'
|
import { artifactPath, ensureDir, exists, listFiles, readJson, sha256File, writeJson } from '../lib/files.js'
|
||||||
import { buildApiCatalog, matchApiEndpointCandidates } from '../lib/api-docs.js'
|
import { buildApiCatalog, matchApiEndpointCandidates } from '../lib/api-docs.js'
|
||||||
|
import { buildBddContract, renderBddContract } from '../lib/bdd.js'
|
||||||
import { buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../lib/html.js'
|
import { buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../lib/html.js'
|
||||||
import { buildMaintenanceContract } from '../lib/maintenance.js'
|
import { buildMaintenanceContract } from '../lib/maintenance.js'
|
||||||
import { resolvePlaywrightCli } from '../lib/playwright-cli.js'
|
import { resolvePlaywrightCli } from '../lib/playwright-cli.js'
|
||||||
@@ -42,9 +43,11 @@ export async function scan() {
|
|||||||
enrichSpecsWithRouteContracts(layoutSpecs, appMap, apiCatalog, config.prototypeDir)
|
enrichSpecsWithRouteContracts(layoutSpecs, appMap, apiCatalog, config.prototypeDir)
|
||||||
for (const spec of layoutSpecs) {
|
for (const spec of layoutSpecs) {
|
||||||
const name = artifactPath(config.prototypeDir, spec.source)
|
const name = artifactPath(config.prototypeDir, spec.source)
|
||||||
|
spec.bddContract = buildBddContract(spec)
|
||||||
await writeJson(join(config.htDir, 'spec', `${name}.spec.json`), spec)
|
await writeJson(join(config.htDir, 'spec', `${name}.spec.json`), spec)
|
||||||
await writeJson(join(config.htDir, 'spec', `${name}.validation.json`), spec.validation)
|
await writeJson(join(config.htDir, 'spec', `${name}.validation.json`), spec.validation)
|
||||||
await writeFile(join(config.htDir, 'spec', `${name}.ui-contract.md`), renderUiContract(spec))
|
await writeFile(join(config.htDir, 'spec', `${name}.ui-contract.md`), renderUiContract(spec))
|
||||||
|
await writeFile(join(config.htDir, 'spec', `${name}.bdd.md`), renderBddContract(spec))
|
||||||
}
|
}
|
||||||
await writeJson(join(config.htDir, 'api-catalog.json'), apiCatalog)
|
await writeJson(join(config.htDir, 'api-catalog.json'), apiCatalog)
|
||||||
await writeJson(join(config.htDir, 'app-map.json'), appMap)
|
await writeJson(join(config.htDir, 'app-map.json'), appMap)
|
||||||
@@ -176,7 +179,7 @@ async function captureRenderedPrototype(config, plan, serverUrl) {
|
|||||||
const playwrightCwd = join(config.htDir, 'cache/playwright-cli')
|
const playwrightCwd = join(config.htDir, 'cache/playwright-cli')
|
||||||
await ensureDir(playwrightCwd)
|
await ensureDir(playwrightCwd)
|
||||||
const commandCwd = resolved.source === 'npx-local' ? config.cwd : playwrightCwd
|
const commandCwd = resolved.source === 'npx-local' ? config.cwd : playwrightCwd
|
||||||
const session = `ht-scan-${process.pid}-${plan.name.replace(/[^a-z0-9_-]+/gi, '-')}`
|
const session = `ht-${process.pid}-${plan.hash.slice(0, 8)}`
|
||||||
const url = new URL(`${encodePath(plan.name)}.html`, serverUrl).href
|
const url = new URL(`${encodePath(plan.name)}.html`, serverUrl).href
|
||||||
const screenshots = []
|
const screenshots = []
|
||||||
const externalResourceFailures = []
|
const externalResourceFailures = []
|
||||||
@@ -537,6 +540,15 @@ function renderUiContract(spec) {
|
|||||||
lines.push(`- ${action.kind}/${action.scope}/${action.actionType}: ${action.label}`)
|
lines.push(`- ${action.kind}/${action.scope}/${action.actionType}: ${action.label}`)
|
||||||
}
|
}
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
lines.push('## BDD Scenarios', '')
|
||||||
|
if (!spec.bddContract?.scenarios?.length) lines.push('- none')
|
||||||
|
for (const scenario of spec.bddContract?.scenarios ?? []) {
|
||||||
|
lines.push(`- ${scenario.type}: ${scenario.name}`)
|
||||||
|
}
|
||||||
|
if (spec.bddContract?.warnings?.length) {
|
||||||
|
lines.push(`- Requires human review: ${spec.bddContract.requiresHumanReview ? 'yes' : 'no'}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
lines.push('## API Endpoints', '')
|
lines.push('## API Endpoints', '')
|
||||||
if (!spec.apiContract?.endpoints?.length) lines.push('- none')
|
if (!spec.apiContract?.endpoints?.length) lines.push('- none')
|
||||||
for (const endpoint of spec.apiContract?.endpoints ?? []) {
|
for (const endpoint of spec.apiContract?.endpoints ?? []) {
|
||||||
|
|||||||
@@ -35,14 +35,20 @@ test('CLI runs doctor and scan against one prototype', async () => {
|
|||||||
await exec('node', [cli, 'scan'], { cwd })
|
await exec('node', [cli, 'scan'], { cwd })
|
||||||
|
|
||||||
const contract = await readFile(join(cwd, '.ht/spec/index.ui-contract.md'), 'utf8')
|
const contract = await readFile(join(cwd, '.ht/spec/index.ui-contract.md'), 'utf8')
|
||||||
|
const bdd = await readFile(join(cwd, '.ht/spec/index.bdd.md'), 'utf8')
|
||||||
const spec = JSON.parse(await readFile(join(cwd, '.ht/spec/index.spec.json'), 'utf8'))
|
const spec = JSON.parse(await readFile(join(cwd, '.ht/spec/index.spec.json'), 'utf8'))
|
||||||
const validation = JSON.parse(await readFile(join(cwd, '.ht/spec/index.validation.json'), 'utf8'))
|
const validation = JSON.parse(await readFile(join(cwd, '.ht/spec/index.validation.json'), 'utf8'))
|
||||||
const appMap = JSON.parse(await readFile(join(cwd, '.ht/app-map.json'), 'utf8'))
|
const appMap = JSON.parse(await readFile(join(cwd, '.ht/app-map.json'), 'utf8'))
|
||||||
|
|
||||||
assert.match(doctor.stdout, /ok prototype directory/)
|
assert.match(doctor.stdout, /ok prototype directory/)
|
||||||
assert.match(contract, /Customer Portal/)
|
assert.match(contract, /Customer Portal/)
|
||||||
|
assert.match(contract, /BDD Scenarios/)
|
||||||
|
assert.match(bdd, /Feature: Customer portal entry/)
|
||||||
|
assert.match(bdd, /Scenario: 使用者填寫必要資料並送出成功/)
|
||||||
assert.doesNotMatch(contract, /Recommended template/)
|
assert.doesNotMatch(contract, /Recommended template/)
|
||||||
assert.equal(spec.pageContract.title, null)
|
assert.equal(spec.pageContract.title, null)
|
||||||
|
assert.equal(spec.bddContract.feature, 'Customer portal entry')
|
||||||
|
assert.equal(spec.bddContract.scenarios[0].type, 'application-submit')
|
||||||
assert.deepEqual(pick(spec.pageContract.forms[0].fields[0], ['name', 'label', 'type', 'required']), {
|
assert.deepEqual(pick(spec.pageContract.forms[0].fields[0], ['name', 'label', 'type', 'required']), {
|
||||||
name: 'email',
|
name: 'email',
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { buildApiCatalog, matchApiEndpointCandidates, matchApiEndpoints } from '../src/lib/api-docs.js'
|
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 { buildMaintenanceContract } from '../src/lib/maintenance.js'
|
||||||
import { buildAppMap, buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../src/lib/html.js'
|
import { buildAppMap, buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../src/lib/html.js'
|
||||||
|
|
||||||
@@ -409,6 +410,130 @@ test('buildMaintenanceContract ignores non-vue guide text for primary entity', (
|
|||||||
assert.deepEqual(contract.capabilities, ['print'])
|
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: ['登入失敗時顯示錯誤訊息', '連續錯誤三次鎖定帳號'],
|
||||||
|
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) {
|
function buildSpec(source, html) {
|
||||||
const regions = extractRegions(html)
|
const regions = extractRegions(html)
|
||||||
const domSummary = summarizeHtml(html)
|
const domSummary = summarizeHtml(html)
|
||||||
|
|||||||
Reference in New Issue
Block a user