@epoint-testtech/ep-stage-skill 0.0.3-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/SKILL.md +27 -0
  2. package/codex-skill/ep-stage/create-project/SKILL.md +59 -0
  3. package/codex-skill/ep-stage/glue-test/SKILL.md +258 -0
  4. package/codex-skill/ep-stage/glue-test/references/crud-pipeline.md +139 -0
  5. package/codex-skill/ep-stage/glue-test/references/gap-review-protocol.md +43 -0
  6. package/codex-skill/ep-stage/glue-test/references/harness-principles.md +46 -0
  7. package/codex-skill/ep-stage/glue-test/scripts/generate-crud-spec.mjs +149 -0
  8. package/codex-skill/ep-stage/glue-testcase/SKILL.md +31 -0
  9. package/codex-skill/ep-stage/glue-testcase/examples/observable-testcase.json +40 -0
  10. package/codex-skill/ep-stage/glue-testcase/references/testcase-schema.md +67 -0
  11. package/codex-skill/ep-stage/recording-to-glue/SKILL.md +27 -0
  12. package/codex-skill/ep-stage/scripts/validate-skill.mjs +73 -0
  13. package/dist/src/capability/coverage-diff.d.ts +34 -0
  14. package/dist/src/capability/coverage-diff.d.ts.map +1 -0
  15. package/dist/src/capability/coverage-diff.js +91 -0
  16. package/dist/src/capability/page-structure.d.ts +31 -0
  17. package/dist/src/capability/page-structure.d.ts.map +1 -0
  18. package/dist/src/capability/page-structure.js +50 -0
  19. package/dist/src/capability/scenario-inference.d.ts +36 -0
  20. package/dist/src/capability/scenario-inference.d.ts.map +1 -0
  21. package/dist/src/capability/scenario-inference.js +114 -0
  22. package/dist/src/cli/generate-crud-contract.d.ts +2 -0
  23. package/dist/src/cli/generate-crud-contract.d.ts.map +1 -0
  24. package/dist/src/cli/generate-crud-contract.js +77 -0
  25. package/dist/src/cli/generate-playwright-tests.d.ts +30 -0
  26. package/dist/src/cli/generate-playwright-tests.d.ts.map +1 -0
  27. package/dist/src/cli/generate-playwright-tests.js +81 -0
  28. package/dist/src/cli/run-gap-pipeline.d.ts +256 -0
  29. package/dist/src/cli/run-gap-pipeline.d.ts.map +1 -0
  30. package/dist/src/cli/run-gap-pipeline.js +1468 -0
  31. package/dist/src/context/stage-context.d.ts +63 -0
  32. package/dist/src/context/stage-context.d.ts.map +1 -0
  33. package/dist/src/context/stage-context.js +297 -0
  34. package/dist/src/contracts/crud-business-module.d.ts +645 -0
  35. package/dist/src/contracts/crud-business-module.d.ts.map +1 -0
  36. package/dist/src/contracts/crud-business-module.js +1 -0
  37. package/dist/src/contracts/gap-inference.d.ts +213 -0
  38. package/dist/src/contracts/gap-inference.d.ts.map +1 -0
  39. package/dist/src/contracts/gap-inference.js +11 -0
  40. package/dist/src/contracts/observable-chain.d.ts +250 -0
  41. package/dist/src/contracts/observable-chain.d.ts.map +1 -0
  42. package/dist/src/contracts/observable-chain.js +1 -0
  43. package/dist/src/extractors/code-list.d.ts +40 -0
  44. package/dist/src/extractors/code-list.d.ts.map +1 -0
  45. package/dist/src/extractors/code-list.js +225 -0
  46. package/dist/src/extractors/html-page.d.ts +67 -0
  47. package/dist/src/extractors/html-page.d.ts.map +1 -0
  48. package/dist/src/extractors/html-page.js +195 -0
  49. package/dist/src/extractors/java-action.d.ts +8 -0
  50. package/dist/src/extractors/java-action.d.ts.map +1 -0
  51. package/dist/src/extractors/java-action.js +53 -0
  52. package/dist/src/extractors/spec-yaml.d.ts +28 -0
  53. package/dist/src/extractors/spec-yaml.d.ts.map +1 -0
  54. package/dist/src/extractors/spec-yaml.js +29 -0
  55. package/dist/src/gap-planner/action-candidates.d.ts +9 -0
  56. package/dist/src/gap-planner/action-candidates.d.ts.map +1 -0
  57. package/dist/src/gap-planner/action-candidates.js +66 -0
  58. package/dist/src/gap-planner/list-gap-workflows.d.ts +17 -0
  59. package/dist/src/gap-planner/list-gap-workflows.d.ts.map +1 -0
  60. package/dist/src/gap-planner/list-gap-workflows.js +47 -0
  61. package/dist/src/gap-planner/plan-agent-workflows.d.ts +26 -0
  62. package/dist/src/gap-planner/plan-agent-workflows.d.ts.map +1 -0
  63. package/dist/src/gap-planner/plan-agent-workflows.js +116 -0
  64. package/dist/src/gap-planner/skeleton-coverage.d.ts +9 -0
  65. package/dist/src/gap-planner/skeleton-coverage.d.ts.map +1 -0
  66. package/dist/src/gap-planner/skeleton-coverage.js +41 -0
  67. package/dist/src/gap-planner/stable-id.d.ts +16 -0
  68. package/dist/src/gap-planner/stable-id.d.ts.map +1 -0
  69. package/dist/src/gap-planner/stable-id.js +19 -0
  70. package/dist/src/generalization/generalization-eval.d.ts +71 -0
  71. package/dist/src/generalization/generalization-eval.d.ts.map +1 -0
  72. package/dist/src/generalization/generalization-eval.js +53 -0
  73. package/dist/src/generators/agent-inferred-workflow-script.d.ts +22 -0
  74. package/dist/src/generators/agent-inferred-workflow-script.d.ts.map +1 -0
  75. package/dist/src/generators/agent-inferred-workflow-script.js +230 -0
  76. package/dist/src/generators/stage-skeleton-script.d.ts +107 -0
  77. package/dist/src/generators/stage-skeleton-script.d.ts.map +1 -0
  78. package/dist/src/generators/stage-skeleton-script.js +607 -0
  79. package/dist/src/index.d.ts +52 -0
  80. package/dist/src/index.d.ts.map +1 -0
  81. package/dist/src/index.js +26 -0
  82. package/dist/src/material/material-inventory.d.ts +92 -0
  83. package/dist/src/material/material-inventory.d.ts.map +1 -0
  84. package/dist/src/material/material-inventory.js +191 -0
  85. package/dist/src/normalizers/crud-contract.d.ts +107 -0
  86. package/dist/src/normalizers/crud-contract.d.ts.map +1 -0
  87. package/dist/src/normalizers/crud-contract.js +1068 -0
  88. package/dist/src/testcase/testcase-generator.d.ts +43 -0
  89. package/dist/src/testcase/testcase-generator.d.ts.map +1 -0
  90. package/dist/src/testcase/testcase-generator.js +152 -0
  91. package/dist/src/testcase/testcase-skeleton.d.ts +91 -0
  92. package/dist/src/testcase/testcase-skeleton.d.ts.map +1 -0
  93. package/dist/src/testcase/testcase-skeleton.js +121 -0
  94. package/dist/src/testcase/testcase-spec-assembly.d.ts +11 -0
  95. package/dist/src/testcase/testcase-spec-assembly.d.ts.map +1 -0
  96. package/dist/src/testcase/testcase-spec-assembly.js +24 -0
  97. package/dist/src/trace/review-summary.d.ts +17 -0
  98. package/dist/src/trace/review-summary.d.ts.map +1 -0
  99. package/dist/src/trace/review-summary.js +34 -0
  100. package/dist/src/trace/trace-writer.d.ts +17 -0
  101. package/dist/src/trace/trace-writer.d.ts.map +1 -0
  102. package/dist/src/trace/trace-writer.js +81 -0
  103. package/dist/test/crud-contract.test.d.ts +2 -0
  104. package/dist/test/crud-contract.test.d.ts.map +1 -0
  105. package/dist/test/crud-contract.test.js +819 -0
  106. package/dist/test/gap-inference.test.d.ts +2 -0
  107. package/dist/test/gap-inference.test.d.ts.map +1 -0
  108. package/dist/test/gap-inference.test.js +597 -0
  109. package/dist/test/generalization.test.d.ts +2 -0
  110. package/dist/test/generalization.test.d.ts.map +1 -0
  111. package/dist/test/generalization.test.js +73 -0
  112. package/dist/test/material-inventory.test.d.ts +2 -0
  113. package/dist/test/material-inventory.test.d.ts.map +1 -0
  114. package/dist/test/material-inventory.test.js +141 -0
  115. package/dist/test/observable-chain.test.d.ts +2 -0
  116. package/dist/test/observable-chain.test.d.ts.map +1 -0
  117. package/dist/test/observable-chain.test.js +123 -0
  118. package/dist/test/observable-pipeline.test.d.ts +2 -0
  119. package/dist/test/observable-pipeline.test.d.ts.map +1 -0
  120. package/dist/test/observable-pipeline.test.js +461 -0
  121. package/dist/test/page-structure.test.d.ts +2 -0
  122. package/dist/test/page-structure.test.d.ts.map +1 -0
  123. package/dist/test/page-structure.test.js +45 -0
  124. package/dist/test/scenario-inference.test.d.ts +2 -0
  125. package/dist/test/scenario-inference.test.d.ts.map +1 -0
  126. package/dist/test/scenario-inference.test.js +73 -0
  127. package/dist/test/stage-context.test.d.ts +2 -0
  128. package/dist/test/stage-context.test.d.ts.map +1 -0
  129. package/dist/test/stage-context.test.js +263 -0
  130. package/dist/test/testcase-generator.test.d.ts +2 -0
  131. package/dist/test/testcase-generator.test.d.ts.map +1 -0
  132. package/dist/test/testcase-generator.test.js +276 -0
  133. package/dist/test/testcase-skeleton.test.d.ts +2 -0
  134. package/dist/test/testcase-skeleton.test.d.ts.map +1 -0
  135. package/dist/test/testcase-skeleton.test.js +185 -0
  136. package/dist/test/testcase-spec-assembly.test.d.ts +2 -0
  137. package/dist/test/testcase-spec-assembly.test.d.ts.map +1 -0
  138. package/dist/test/testcase-spec-assembly.test.js +105 -0
  139. package/dist/vitest.config.d.ts +3 -0
  140. package/dist/vitest.config.d.ts.map +1 -0
  141. package/dist/vitest.config.js +7 -0
  142. package/docs/README.md +134 -0
  143. package/docs/mvp-usage-guide.md +298 -0
  144. package/examples/schemeresource-observable-docs/schemeresource.context.md +20 -0
  145. package/examples/schemeresource.module-hints.json +38 -0
  146. package/examples/schemeresource.observable.code_list.md +37 -0
  147. package/examples/zwplace-observable-docs/zwplace.context.md +16 -0
  148. package/examples/zwplace-placecategory-validation.json +29 -0
  149. package/examples/zwplace.module-hints.json +69 -0
  150. package/examples/zwplace.observable.code_list.md +37 -0
  151. package/package.json +38 -0
@@ -0,0 +1,1068 @@
1
+ /**
2
+ * CRUD 业务模块契约 Normalizer
3
+ *
4
+ * 核心职责:
5
+ * - 将上游 extractor 提取的结构化信息归一化为 crud-business-module/v1 契约
6
+ * - 消费 ModuleHints 显式输入的测试策略(dataKey、buttonAliases、deletePolicy、assertionPolicy 等)
7
+ * - 优先使用结构化证据,证据不足时输出 unresolvedSlots
8
+ * - 不猜测、不编造,确保契约的每个字段都有来源溯源
9
+ *
10
+ * 设计原则:
11
+ * - 确定性优先:能从源码确定的值直接确定,不能确定的通过 hints 或 unresolvedSlots 处理
12
+ * - 来源溯源:每个字段都带有 sources 数组,记录信息来源(html/spec/java_action/human_required)
13
+ * - 策略隔离:测试策略(如 dataKey 选择、删除预期)与结构提取分离,通过 hints 传入
14
+ *
15
+ * 修复历史(Code Review 问题闭环):
16
+ * - overrideFields 丢失 strategy/value → 新增 OverrideFieldContract,透传 strategy/value
17
+ * - assertionPolicy 定义未消费 → buildAssertions 接收 hints,afterCreate/afterDelete 策略生效
18
+ * - deletePolicy 输入-输出不对称 → 将 prepare_deletable_draft_then_delete 提升为正式成员
19
+ * - grid 精细分类未消费 → GridContract 增加 selectionColumn/indexColumn/rowActions
20
+ * - findButton 单标签匹配 → 支持别名列表,searchFlow 消费 buttonAliases
21
+ * - '不可删除' 硬编码 → 提取为 BLOCK_DELETE_KEYWORDS 常量
22
+ */
23
+ import { planAgentInferredWorkflows } from '../gap-planner/plan-agent-workflows.js';
24
+ /**
25
+ * 默认按钮标签配置
26
+ *
27
+ * 当未通过 buttonLabels 或 hints 指定时使用的默认值。
28
+ * 这些值与公司自研 zwplace 框架的标准按钮文本一致。
29
+ */
30
+ const DEFAULT_BUTTON_LABELS = {
31
+ create: '新增',
32
+ saveAndClose: '保存并关闭',
33
+ delete: '删除选定',
34
+ search: '搜索',
35
+ edit: '修改'
36
+ };
37
+ /**
38
+ * 从 hints 中获取按钮别名
39
+ *
40
+ * 优先使用 hints.buttonAliases 中配置的第一个别名。
41
+ *
42
+ * @param hints - 模块测试策略 hints
43
+ * @param key - 按钮类型键名
44
+ * @returns 按钮别名,若未配置则返回 undefined
45
+ */
46
+ function hintedButtonAlias(hints, key) {
47
+ return hints?.buttonAliases?.[key]?.[0];
48
+ }
49
+ /**
50
+ * 解析按钮标签配置
51
+ *
52
+ * 按优先级确定各个按钮的显示文本:
53
+ * 1. buttonLabels 参数显式指定
54
+ * 2. hints.buttonAliases 中的别名
55
+ * 3. 默认值(DEFAULT_BUTTON_LABELS 或模块标签拼接)
56
+ *
57
+ * @param input - 可选的按钮标签覆盖
58
+ * @param moduleLabel - 模块显示名称(用于生成默认的"新增xxx"按钮文本)
59
+ * @param hints - 可选的模块测试策略 hints
60
+ * @returns 完整的按钮标签配置
61
+ */
62
+ function resolveButtonLabels(input, moduleLabel, hints) {
63
+ return {
64
+ create: input?.create ?? hintedButtonAlias(hints, 'create') ?? `新增${moduleLabel}`,
65
+ saveAndClose: input?.saveAndClose ?? hintedButtonAlias(hints, 'saveAndClose') ?? DEFAULT_BUTTON_LABELS.saveAndClose,
66
+ delete: input?.delete ?? hintedButtonAlias(hints, 'delete') ?? DEFAULT_BUTTON_LABELS.delete,
67
+ search: input?.search ?? hintedButtonAlias(hints, 'search') ?? DEFAULT_BUTTON_LABELS.search,
68
+ edit: input?.edit ?? hintedButtonAlias(hints, 'edit') ?? DEFAULT_BUTTON_LABELS.edit
69
+ };
70
+ }
71
+ /**
72
+ * 推断页面角色
73
+ *
74
+ * 根据页面 ID 后缀推断其在 CRUD 流程中的角色。
75
+ * 规则:
76
+ * - 以 'add' 结尾 → 'add'(新增页面)
77
+ * - 以 'edit' 结尾 → 'edit'(编辑页面)
78
+ * - 以 'detail' 结尾 → 'detail'(详情页面)
79
+ * - 其他 → 'list'(列表页面)
80
+ *
81
+ * @param pageId - 页面标识符
82
+ * @returns 页面角色类型
83
+ */
84
+ function inferRole(pageId) {
85
+ if (pageId.endsWith('add'))
86
+ return 'add';
87
+ if (pageId.endsWith('edit'))
88
+ return 'edit';
89
+ if (pageId.endsWith('detail'))
90
+ return 'detail';
91
+ return 'list';
92
+ }
93
+ /**
94
+ * 创建 HTML 来源引用
95
+ *
96
+ * @param page - 提取的 HTML 页面信息
97
+ * @returns 来源引用对象,置信度为 high
98
+ */
99
+ function htmlSource(page) {
100
+ return { kind: 'html', path: page.path, confidence: 'high' };
101
+ }
102
+ /**
103
+ * 创建 spec.yaml 来源引用
104
+ *
105
+ * @param spec - 提取的 spec.yaml 信息
106
+ * @returns 来源引用对象,置信度为 high
107
+ */
108
+ function specSource(spec) {
109
+ return { kind: 'spec', path: spec.path, confidence: 'high' };
110
+ }
111
+ /**
112
+ * 将提取的字段信息转换为字段契约
113
+ *
114
+ * 将 HTML extractor 提取的字段结构信息转换为契约格式。
115
+ * availability 固定为 'auto_extractable',表示该字段信息可从源码自动提取。
116
+ *
117
+ * 上游物料关联:
118
+ * - 字段信息:从 HTML 页面的 `<input>` 元素提取
119
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/webapp/perpage/zwplace/*.html`
120
+ * - 提取示例:`<div role="control" label="场所名称"><input bind="dataBean.placename" class="mini-textbox" /></div>`
121
+ * - 提取规则:解析 `role="control"` 的 div 元素,提取 label、bind、class 等属性
122
+ * - 来源字段:
123
+ * - `field.name`:从 `bind` 属性提取(如 `dataBean.placename` → `placename`)
124
+ * - `field.label`:从 `label` 属性提取(如 `场所名称`)
125
+ * - `field.controlType`:从 `class` 属性提取(如 `mini-textbox`、`mini-combobox`)
126
+ * - `field.required`:从 `required` 或 `starred` 属性提取
127
+ * - `field.maxLength`:从 `maxLength` 属性提取
128
+ * - `field.locator`:生成 XPath 定位器(如 `//div[@label='场所名称']//input`)
129
+ *
130
+ * @param field - 提取的字段信息(来自 html-page extractor)
131
+ * @param source - 来源引用(来自 htmlSource)
132
+ * @returns 字段契约对象
133
+ */
134
+ function toFieldContract(field, source) {
135
+ return {
136
+ name: field.name,
137
+ label: field.label,
138
+ controlType: field.controlType,
139
+ bind: field.bind,
140
+ required: field.required,
141
+ maxLength: field.maxLength,
142
+ locator: field.locator,
143
+ availability: 'auto_extractable',
144
+ sources: [source]
145
+ };
146
+ }
147
+ /**
148
+ * 将提取的按钮信息转换为按钮契约
149
+ *
150
+ * 上游物料关联:
151
+ * - 按钮信息:从 HTML 页面的 `<a>` 或 `<button>` 元素提取
152
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/webapp/perpage/zwplace/*.html`
153
+ * - 提取示例:`<a class="mini-button" onclick="openAdd" state="primary"> 新增场所信息管理 </a>`
154
+ * - 提取规则:解析 `class="mini-button"` 的 a 元素,提取文本、onclick、id 等属性
155
+ * - 来源字段:
156
+ * - `button.id`:从 `id` 属性提取(若无则自动生成)
157
+ * - `button.label`:从元素文本提取(如 `新增场所信息管理`)
158
+ * - `button.locator`:生成 XPath 定位器(如 `//*[contains(@class,'mini-button')][normalize-space()='新增场所信息管理']`)
159
+ *
160
+ * @param button - 提取的按钮信息(来自 html-page extractor)
161
+ * @param source - 来源引用(来自 htmlSource)
162
+ * @returns 按钮契约对象
163
+ */
164
+ function toButtonContract(button, source) {
165
+ return {
166
+ id: button.id,
167
+ label: button.label,
168
+ onclick: button.onclick,
169
+ locator: button.locator,
170
+ availability: 'auto_extractable',
171
+ sources: [source]
172
+ };
173
+ }
174
+ /**
175
+ * 将提取的表格信息转换为表格契约
176
+ *
177
+ * 支持表格的精细分类:
178
+ * - columns: 数据列(业务字段列)
179
+ * - selectionColumn: 选择列(如 checkbox 列)
180
+ * - indexColumn: 序号列
181
+ * - rowActions: 行操作(如修改、查看按钮)
182
+ *
183
+ * 上游物料关联:
184
+ * - 表格信息:从 HTML 页面的 `<div class="mini-datagrid">` 元素提取
185
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/webapp/perpage/zwplace/*.html`
186
+ * - 提取示例:`<div id="datagrid" class="mini-datagrid">...</div>`
187
+ * - 提取规则:解析 `class="mini-datagrid"` 的 div 元素,提取列定义和行操作
188
+ * - 来源字段:
189
+ * - `grid.id`:从 `id` 属性提取(如 `datagrid`)
190
+ * - `grid.columns[]`:从 `<div property="columns">` 子元素提取
191
+ * - `column.field`:从 `field` 属性提取(如 `placename`)
192
+ * - `column.headerText`:从元素文本提取(如 `场所名称`)
193
+ * - `column.columnType`:从 `type` 属性提取(如 `data`、`checkcolumn`、`indexcolumn`)
194
+ * - `grid.selectionColumn`:从 `type="checkcolumn"` 的列提取
195
+ * - `grid.indexColumn`:从 `type="indexcolumn"` 的列提取
196
+ * - `grid.rowActions[]`:从 `type="actioncolumn"` 的列中的 `<a type="action">` 提取
197
+ * - `action.label`:从元素文本提取(如 `修改`、`查看`)
198
+ * - `action.locator`:生成 XPath 定位器(如 `xpath=//i[@data-tooltip='修改']`)
199
+ *
200
+ * @param grid - 提取的表格信息(来自 html-page extractor)
201
+ * @param source - 来源引用(来自 htmlSource)
202
+ * @returns 表格契约对象
203
+ */
204
+ function toGridContract(grid, source) {
205
+ return {
206
+ id: grid.id,
207
+ // 数据列映射
208
+ columns: grid.columns.map((col) => ({
209
+ field: col.field,
210
+ headerText: col.headerText,
211
+ columnType: col.columnType,
212
+ sources: [source]
213
+ })),
214
+ // 选择列(可选)
215
+ ...(grid.selectionColumn
216
+ ? {
217
+ selectionColumn: {
218
+ field: grid.selectionColumn.field,
219
+ headerText: grid.selectionColumn.headerText,
220
+ columnType: grid.selectionColumn.columnType,
221
+ sources: [source]
222
+ }
223
+ }
224
+ : {}),
225
+ // 序号列(可选)
226
+ ...(grid.indexColumn
227
+ ? {
228
+ indexColumn: {
229
+ field: grid.indexColumn.field,
230
+ headerText: grid.indexColumn.headerText,
231
+ columnType: grid.indexColumn.columnType,
232
+ sources: [source]
233
+ }
234
+ }
235
+ : {}),
236
+ // 行操作(可选)
237
+ ...(grid.rowActions && grid.rowActions.length > 0
238
+ ? {
239
+ rowActions: grid.rowActions.map((ra) => ({
240
+ label: ra.label,
241
+ onclick: ra.onclick,
242
+ locator: ra.locator,
243
+ availability: 'auto_extractable',
244
+ sources: [source]
245
+ }))
246
+ }
247
+ : {}),
248
+ availability: 'auto_extractable',
249
+ sources: [source]
250
+ };
251
+ }
252
+ /**
253
+ * 构建页面契约
254
+ *
255
+ * 将提取的 HTML 页面信息转换为契约格式,包含:
256
+ * - 页面角色(list/add/edit/detail)
257
+ * - 页面标识和标题
258
+ * - iframe 关键字(用于定位嵌套 iframe)
259
+ * - 字段、按钮、表格、弹窗等元素的契约化
260
+ *
261
+ * 上游物料关联:
262
+ * - 页面信息:从 HTML 页面文件提取
263
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/webapp/perpage/zwplace/*.html`
264
+ * - 提取规则:通过 html-page extractor 解析 HTML 结构
265
+ * - 提取位置:`ExtractedHtmlPage` 对象
266
+ * - 来源字段:`page.pageId`(页面标识)、`page.title`(页面标题)、`page.path`(文件路径)
267
+ *
268
+ * - 字段信息:从 HTML 页面的 `<input>` 元素提取
269
+ * - 提取位置:`page.fields[]` 数组
270
+ * - 来源字段:`field.name`、`field.label`、`field.controlType`、`field.locator`
271
+ *
272
+ * - 按钮信息:从 HTML 页面的 `<a>` 或 `<button>` 元素提取
273
+ * - 提取位置:`page.buttons[]` 数组
274
+ * - 来源字段:`button.id`、`button.label`、`button.locator`
275
+ *
276
+ * - 表格信息:从 HTML 页面的 `<div class="mini-datagrid">` 元素提取
277
+ * - 提取位置:`page.grids[]` 数组
278
+ * - 来源字段:`grid.id`、`grid.columns[]`
279
+ *
280
+ * - 弹窗信息:从 HTML 页面的 `epoint.openTopDialog(...)` 调用提取
281
+ * - 提取位置:`page.dialogs[]` 数组
282
+ * - 来源字段:`dialog.trigger`、`dialog.title`、`dialog.pageId`
283
+ *
284
+ * @param page - 提取的 HTML 页面信息(来自 html-page extractor)
285
+ * @param role - 页面角色类型(来自 inferRole)
286
+ * @returns 页面契约对象
287
+ */
288
+ function buildPageContract(page, role) {
289
+ const source = htmlSource(page);
290
+ return {
291
+ role,
292
+ pageId: page.pageId,
293
+ title: page.title,
294
+ iframeSrcKeyword: page.pageId,
295
+ fields: page.fields.map((f) => toFieldContract(f, source)),
296
+ buttons: page.buttons.map((b) => toButtonContract(b, source)),
297
+ grids: page.grids.map((g) => toGridContract(g, source)),
298
+ dialogs: page.dialogs.map((d) => ({
299
+ trigger: d.trigger,
300
+ title: d.title,
301
+ pageId: d.pageId,
302
+ pagePath: d.pagePath,
303
+ availability: 'auto_extractable',
304
+ sources: [source]
305
+ })),
306
+ sources: [source]
307
+ };
308
+ }
309
+ /**
310
+ * 在页面中查找按钮
311
+ *
312
+ * 支持单个标签或标签别名列表匹配。
313
+ * 遍历所有页面,对每个页面的按钮尝试匹配别名列表中的任意一个。
314
+ *
315
+ * @param pages - 页面数组
316
+ * @param labelOrAliases - 单个标签字符串或标签别名数组
317
+ * @returns 找到的页面和按钮对象,若未找到则返回 undefined
318
+ */
319
+ function findButton(pages, labelOrAliases) {
320
+ const aliases = Array.isArray(labelOrAliases) ? labelOrAliases : [labelOrAliases];
321
+ for (const page of pages) {
322
+ for (const alias of aliases) {
323
+ const button = page.buttons.find((b) => b.label === alias);
324
+ if (button)
325
+ return { page, button };
326
+ }
327
+ }
328
+ return undefined;
329
+ }
330
+ /**
331
+ * 从按钮信息创建流程控制引用
332
+ *
333
+ * 将提取的按钮信息转换为契约中的 FlowControlRef 格式,
334
+ * 用于表示流程中的交互控件(如"新增"按钮、"保存"按钮等)。
335
+ *
336
+ * @param pageRole - 按钮所在的页面角色
337
+ * @param button - 提取的按钮信息
338
+ * @param source - 来源引用
339
+ * @returns 流程控制引用对象
340
+ */
341
+ function flowControlFromButton(pageRole, button, source) {
342
+ return {
343
+ pageRole,
344
+ label: button.label,
345
+ locator: button.locator,
346
+ sources: [source]
347
+ };
348
+ }
349
+ /**
350
+ * 创建占位符流程控制引用
351
+ *
352
+ * 当无法从源码中提取按钮信息时,创建一个占位符对象。
353
+ * 来源标记为 'human_required',置信度为 'low',
354
+ * 提示需要人工补充该控件的定位信息。
355
+ *
356
+ * @param pageRole - 按钮所在的页面角色
357
+ * @param label - 按钮显示文本
358
+ * @returns 占位符流程控制引用对象
359
+ */
360
+ function placeholderFlowControl(pageRole, label) {
361
+ return {
362
+ pageRole,
363
+ label,
364
+ sources: [{ kind: 'human_required', path: '', confidence: 'low' }]
365
+ };
366
+ }
367
+ /**
368
+ * 构建创建流程契约
369
+ *
370
+ * 从列表页和新增页中提取创建操作的完整流程信息:
371
+ * 1. 入口按钮:列表页上的"新增"按钮
372
+ * 2. 目标页面:新增页面的 pageId
373
+ * 3. 保存按钮:新增页上的"保存并关闭"按钮
374
+ * 4. 需要覆盖的字段:从 hints.autofill.overrideFields 读取,或使用默认的 dataKeyField
375
+ *
376
+ * 上游物料关联:
377
+ * - 入口按钮:从 HTML 列表页的 `<a>` 元素提取
378
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/webapp/perpage/zwplace/gxhzwplacelist.html:18`
379
+ * - 原文:`<a class="mini-button" onclick="openAdd" state="primary"> 新增场所信息管理 </a>`
380
+ * - 提取规则:匹配 `labels.create` 文本的按钮,支持 `hints.buttonAliases.create` 别名
381
+ * - 提取位置:`page.buttons[]` 数组中的 `ExtractedHtmlButton` 对象
382
+ * - 来源字段:`button.label`(按钮文本)、`button.locator`(XPath 定位器)
383
+ *
384
+ * - 目标页面:从 HTML 新增页的 `pageId` 提取
385
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/webapp/perpage/zwplace/gxhzwplaceadd.html`
386
+ * - 提取规则:从 HTML 文件名推断页面角色(以 'add' 结尾)
387
+ * - 提取位置:`page.pageId` 字段
388
+ *
389
+ * - 保存按钮:从 HTML 新增页的 `<a>` 元素提取
390
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/webapp/perpage/zwplace/gxhzwplaceadd.html`
391
+ * - 提取规则:匹配 `labels.saveAndClose` 文本的按钮
392
+ * - 来源字段:`button.label`、`button.locator`
393
+ *
394
+ * - 覆盖字段:从 `hints.autofill.overrideFields` 读取,或使用 `dataKeyField` 默认值
395
+ * - 来源:`ModuleHints`(测试策略输入)
396
+ * - 透传字段:`strategy`(生成策略)、`value`(固定值)
397
+ *
398
+ * overrideFields 处理逻辑:
399
+ * - 优先使用 hints.autofill.overrideFields 中配置的字段和策略
400
+ * - 若未配置,使用 dataKeyField 作为默认覆盖字段,策略为 'human_required'
401
+ * - 透传 strategy 和 value 到契约中,供下游 Playwright 脚本生成使用
402
+ *
403
+ * @param pages - 页面数组(来自 html-page extractor)
404
+ * @param labels - 按钮标签配置(来自 resolveButtonLabels)
405
+ * @param dataKeyField - 数据主键字段名(来自 resolveDataKeyField)
406
+ * @param hints - 可选的模块测试策略 hints(来自 ModuleHints)
407
+ * @returns 创建流程契约对象,若缺少必要页面则返回 undefined
408
+ */
409
+ function buildCreateFlow(pages, labels, dataKeyField, hints) {
410
+ const listPage = pages.find((p) => inferRole(p.pageId) === 'list');
411
+ const addPage = pages.find((p) => inferRole(p.pageId) === 'add');
412
+ if (!listPage || !addPage)
413
+ return undefined;
414
+ const entryMatch = findButton([listPage], labels.create);
415
+ const saveMatch = findButton([addPage], labels.saveAndClose);
416
+ const entryButton = entryMatch
417
+ ? flowControlFromButton('list', entryMatch.button, htmlSource(entryMatch.page))
418
+ : placeholderFlowControl('list', labels.create);
419
+ const saveButton = saveMatch
420
+ ? flowControlFromButton('add', saveMatch.button, htmlSource(saveMatch.page))
421
+ : placeholderFlowControl('add', labels.saveAndClose);
422
+ const overrideFields = (hints?.autofill?.overrideFields ?? [
423
+ { field: dataKeyField, strategy: 'human_required' }
424
+ ]).map((item) => ({
425
+ pageRole: 'add',
426
+ field: item.field,
427
+ strategy: item.strategy,
428
+ value: item.value,
429
+ sources: [htmlSource(addPage)]
430
+ }));
431
+ return {
432
+ entryButton,
433
+ targetPageId: addPage.pageId,
434
+ saveButton,
435
+ overrideFields
436
+ };
437
+ }
438
+ /**
439
+ * 构建搜索流程契约
440
+ *
441
+ * 从列表页中提取搜索操作的完整流程信息:
442
+ * 1. 搜索字段:与 dataKeyField 匹配的列表页字段
443
+ * 2. 提交控件:搜索按钮(支持 buttonAliases 别名匹配)
444
+ * 3. 结果表格:列表页的第一个表格的 ID
445
+ *
446
+ * 上游物料关联:
447
+ * - 搜索字段:从 HTML 列表页的 `<input>` 元素提取
448
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/webapp/perpage/zwplace/gxhzwplacelist.html:28-29`
449
+ * - 原文:`<div role="control" label="场所名称"><input bind="dataBean.placename" class="mini-textbox" /></div>`
450
+ * - 提取规则:匹配 `dataKeyField` 字段名的表单控件
451
+ * - 提取位置:`page.fields[]` 数组中的 `ExtractedHtmlField` 对象
452
+ * - 来源字段:`field.name`(字段名)、`field.label`(标签)、`field.locator`(XPath 定位器)
453
+ *
454
+ * - 提交控件:从 HTML 列表页的 `<a>` 元素提取(搜索按钮在生成式页面中通常由框架渲染)
455
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/webapp/perpage/zwplace/gxhzwplacelist.html`
456
+ * - 提取规则:匹配 `hints.buttonAliases.search` 或 `labels.search` 文本的按钮
457
+ * - 提取位置:`page.buttons[]` 数组中的 `ExtractedHtmlButton` 对象
458
+ * - 来源字段:`button.label`、`button.locator`
459
+ *
460
+ * - 结果表格:从 HTML 列表页的 `<div class="mini-datagrid">` 元素提取
461
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/webapp/perpage/zwplace/gxhzwplacelist.html:49`
462
+ * - 原文:`<div field="placename" width="100">场所名称</div>`(表格列定义)
463
+ * - 提取规则:取列表页的第一个表格的 ID
464
+ * - 提取位置:`page.grids[0].id`
465
+ * - 来源字段:`grid.id`(表格 ID)
466
+ *
467
+ * 搜索按钮匹配逻辑:
468
+ * - 优先使用 hints.buttonAliases.search 中的别名列表
469
+ * - 若未配置,使用 labels.search 作为默认值
470
+ * - 在列表页按钮中查找标签匹配的按钮
471
+ *
472
+ * @param pages - 页面数组(来自 html-page extractor)
473
+ * @param labels - 按钮标签配置(来自 resolveButtonLabels)
474
+ * @param dataKeyField - 数据主键字段名(来自 resolveDataKeyField)
475
+ * @param hints - 可选的模块测试策略 hints(来自 ModuleHints)
476
+ * @returns 搜索流程契约对象,若缺少必要元素则返回 undefined
477
+ */
478
+ function buildSearchFlow(pages, labels, dataKeyField, hints) {
479
+ const listPage = pages.find((p) => inferRole(p.pageId) === 'list');
480
+ if (!listPage)
481
+ return undefined;
482
+ // 查找搜索字段(与 dataKey 匹配)
483
+ const searchField = listPage.fields.find((f) => f.name === dataKeyField);
484
+ // 查找搜索按钮(支持别名匹配)
485
+ const searchAliases = hints?.buttonAliases?.search ?? [labels.search];
486
+ const searchButton = listPage.buttons.find((b) => searchAliases.includes(b.label));
487
+ // 获取结果表格 ID
488
+ const gridId = listPage.grids[0]?.id;
489
+ if (!searchField || !gridId)
490
+ return undefined;
491
+ const source = htmlSource(listPage);
492
+ return {
493
+ searchField: {
494
+ pageRole: 'list',
495
+ field: searchField.name,
496
+ label: searchField.label,
497
+ locator: searchField.locator,
498
+ sources: [source]
499
+ },
500
+ submitControl: searchButton
501
+ ? flowControlFromButton('list', searchButton, source)
502
+ : placeholderFlowControl('list', labels.search),
503
+ resultGrid: gridId
504
+ };
505
+ }
506
+ /**
507
+ * 构建更新流程契约
508
+ *
509
+ * 从列表页和编辑页中提取更新操作的完整流程信息:
510
+ * 1. 入口操作:列表页上的"修改"行操作
511
+ * 2. 目标页面:编辑页面的 pageId
512
+ * 3. 更新字段:与 dataKeyField 匹配的编辑页字段
513
+ * 4. 保存按钮:编辑页上的"保存并关闭"按钮
514
+ *
515
+ * @param pages - 页面数组
516
+ * @param labels - 按钮标签配置
517
+ * @param dataKeyField - 数据主键字段名
518
+ * @returns 更新流程契约对象,若缺少必要页面则返回 undefined
519
+ */
520
+ function buildUpdateFlow(pages, labels, dataKeyField) {
521
+ const listPage = pages.find((p) => inferRole(p.pageId) === 'list');
522
+ const editPage = pages.find((p) => inferRole(p.pageId) === 'edit');
523
+ if (!listPage || !editPage)
524
+ return undefined;
525
+ const source = htmlSource(listPage);
526
+ const editSource = htmlSource(editPage);
527
+ // 查找保存按钮
528
+ const saveMatch = findButton([editPage], labels.saveAndClose);
529
+ const saveButton = saveMatch
530
+ ? flowControlFromButton('edit', saveMatch.button, editSource)
531
+ : placeholderFlowControl('edit', labels.saveAndClose);
532
+ return {
533
+ entryAction: {
534
+ pageRole: 'list',
535
+ label: labels.edit,
536
+ sources: [source]
537
+ },
538
+ targetPageId: editPage.pageId,
539
+ updateField: {
540
+ pageRole: 'edit',
541
+ field: dataKeyField,
542
+ sources: [editSource]
543
+ },
544
+ saveButton
545
+ };
546
+ }
547
+ /**
548
+ * 构建删除流程契约
549
+ *
550
+ * 从列表页中提取删除操作的流程信息:
551
+ * 1. 入口按钮:列表页上的"删除选定"按钮(支持 buttonAliases 别名匹配)
552
+ * 2. 预期结果:根据 hints.deletePolicy 或 Java Action 中的消息推断
553
+ *
554
+ * 上游物料关联:
555
+ * - 入口按钮:从 HTML 列表页的 `<a>` 元素提取
556
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/webapp/perpage/zwplace/gxhzwplacelist.html:19`
557
+ * - 原文:`<a class="mini-button" state="danger" onclick="deleteData" data-unselectdiabled="datagrid"> 删除选定 </a>`
558
+ * - 提取规则:匹配 `hints.buttonAliases.delete` 或 `labels.delete` 文本的按钮
559
+ * - 提取位置:`page.buttons[]` 数组中的 `ExtractedHtmlButton` 对象
560
+ * - 来源字段:`button.label`、`button.locator`
561
+ *
562
+ * - 预期结果(阻止删除消息):从 Java Action 源码提取
563
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/main/java/com/epoint/zwplace/action/GxhZwPlaceListAction.java:51-52`
564
+ * - 原文:`msg = "在用数据不可删除!"; addCallbackParam("error", msg);`
565
+ * - 提取规则:匹配 `addCallbackParam("msg", ...)` 或 `addCallbackParam("error", ...)` 中的中文消息
566
+ * - 提取位置:`action.messages[]` 数组
567
+ * - 关键词匹配:`BLOCK_DELETE_KEYWORDS = ['不可删除', '不能删除', '无法删除']`
568
+ *
569
+ * - 预期结果(策略输入):从 `hints.deletePolicy` 读取
570
+ * - 来源:`ModuleHints`(测试策略输入)
571
+ * - 可选值:`success_delete` | `blocked_by_business_rule` | `prepare_deletable_draft_then_delete` | `human_required`
572
+ *
573
+ * 预期结果推断逻辑:
574
+ * - 优先使用 hints.deletePolicy 中的配置
575
+ * - 若未配置,检查 Java Action 消息中是否包含阻止删除的关键词
576
+ * - 若有阻止关键词,预期结果为 'blocked_by_business_rule'
577
+ * - 否则为 'human_required'
578
+ *
579
+ * @param pages - 页面数组(来自 html-page extractor)
580
+ * @param actions - Java Action 提取信息数组(来自 java-action extractor)
581
+ * @param labels - 按钮标签配置(来自 resolveButtonLabels)
582
+ * @param hints - 可选的模块测试策略 hints(来自 ModuleHints)
583
+ * @returns 删除流程契约对象,若缺少列表页则返回 undefined
584
+ */
585
+ function buildDeleteFlow(pages, actions, labels, hints) {
586
+ const listPage = pages.find((p) => inferRole(p.pageId) === 'list');
587
+ if (!listPage)
588
+ return undefined;
589
+ // 查找删除按钮(支持别名匹配)
590
+ const deleteAliases = hints?.buttonAliases?.delete ?? [labels.delete];
591
+ const deleteMatch = findButton([listPage], deleteAliases);
592
+ const source = htmlSource(listPage);
593
+ const entryButton = deleteMatch
594
+ ? flowControlFromButton('list', deleteMatch.button, source)
595
+ : placeholderFlowControl('list', labels.delete);
596
+ // 推断删除预期结果
597
+ const hasBlockMessage = actions.some((a) => a.messages.some((m) => BLOCK_DELETE_KEYWORDS.some((kw) => m.includes(kw))));
598
+ const expectedOutcome = hints?.deletePolicy ?? (hasBlockMessage ? 'blocked_by_business_rule' : 'human_required');
599
+ return {
600
+ entryButton,
601
+ expectedOutcome
602
+ };
603
+ }
604
+ /**
605
+ * 获取可搜索字段列表
606
+ *
607
+ * 从 spec.yaml 中筛选出 searchable=true 的字段名。
608
+ * 这些字段可作为 dataKey 的候选字段。
609
+ *
610
+ * @param spec - spec.yaml 提取信息
611
+ * @returns 可搜索字段名数组
612
+ */
613
+ function searchableFields(spec) {
614
+ return spec.fields.filter((field) => field.searchable).map((field) => field.name);
615
+ }
616
+ /**
617
+ * 解析数据主键字段
618
+ *
619
+ * 按优先级确定 dataKey 字段:
620
+ * 1. hints.dataKey.selectedField 显式指定
621
+ * 2. input.dataKeyField 显式指定
622
+ * 3. hints.dataKey.candidateFields 中的第一个候选
623
+ * 4. searchableFields 中的第一个可搜索字段
624
+ * 5. spec.fields 中的第一个字段
625
+ * 6. 兜底值 'id'
626
+ *
627
+ * 上游物料关联:
628
+ * - hints.dataKey.selectedField:从 ModuleHints 读取
629
+ * - 来源:`examples/zwplace.module-hints.json` 中的 `dataKey.selectedField` 字段
630
+ * - 示例:`"selectedField": "placename"`
631
+ *
632
+ * - searchableFields:从 spec.yaml 的字段定义提取
633
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/_docs/003-场所窗口信息管理-20260518/spec.yaml`
634
+ * - 提取规则:筛选 `searchable=true` 的字段
635
+ * - 提取位置:`spec.fields[]` 数组中的 `ExtractedSpecField` 对象
636
+ * - 来源字段:`field.name`(字段名)、`field.searchable`(是否可搜索)
637
+ *
638
+ * - spec.fields[0]:从 spec.yaml 的字段定义取第一个字段
639
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/_docs/003-场所窗口信息管理-20260518/spec.yaml`
640
+ * - 提取位置:`spec.fields[0].name`
641
+ *
642
+ * @param spec - spec.yaml 提取信息(来自 spec-yaml extractor)
643
+ * @param input - 构建契约的输入参数(来自 BuildCrudContractInput)
644
+ * @returns 数据主键字段名
645
+ */
646
+ function resolveDataKeyField(spec, input) {
647
+ if (input.hints?.dataKey?.selectedField)
648
+ return input.hints.dataKey.selectedField;
649
+ if (input.dataKeyField)
650
+ return input.dataKeyField;
651
+ const candidates = input.hints?.dataKey?.candidateFields ?? searchableFields(spec);
652
+ return candidates[0] ?? spec.fields[0]?.name ?? 'id';
653
+ }
654
+ /**
655
+ * 检查数据主键策略是否已解决
656
+ *
657
+ * 判断 dataKey 字段是否可以确定,无需人工补充。
658
+ * 已解决的条件:
659
+ * - hints.dataKey.selectedField 已指定
660
+ * - input.dataKeyField 已指定
661
+ * - 可搜索字段只有一个(唯一候选)
662
+ *
663
+ * @param spec - spec.yaml 提取信息
664
+ * @param input - 构建契约的输入参数
665
+ * @returns 若策略已解决返回 true,否则返回 false
666
+ */
667
+ function dataKeyIsPolicyResolved(spec, input) {
668
+ if (input.hints?.dataKey?.selectedField || input.dataKeyField)
669
+ return true;
670
+ return searchableFields(spec).length === 1;
671
+ }
672
+ /**
673
+ * 构建数据主键契约
674
+ *
675
+ * 生成 dataKey 字段的契约信息,包括:
676
+ * - 字段名和标签
677
+ * - 可获取性(derivable/human_required)
678
+ * - 数据生成策略(timestamp_prefix/fixed_value/external_data_pool/human_required)
679
+ * - 来源溯源
680
+ *
681
+ * 上游物料关联:
682
+ * - 字段信息:从 spec.yaml 的字段定义提取
683
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/_docs/003-场所窗口信息管理-20260518/spec.yaml`
684
+ * - 提取规则:匹配 dataKeyField 字段名,获取 label(显示名称)
685
+ * - 提取位置:`spec.fields[]` 数组中的 `ExtractedSpecField` 对象
686
+ * - 来源字段:`field.name`(字段名)、`field.label`(标签)
687
+ *
688
+ * - 数据生成策略:从 `hints.dataKey.generationStrategy` 读取
689
+ * - 来源:`ModuleHints`(测试策略输入)
690
+ * - 可选值:`timestamp_prefix` | `fixed_value` | `external_data_pool` | `human_required`
691
+ *
692
+ * - 可获取性:根据策略是否已解决判断
693
+ * - 若 hints.dataKey.selectedField 或 input.dataKeyField 已指定 → 'derivable'
694
+ * - 若可搜索字段只有一个 → 'derivable'
695
+ * - 否则 → 'human_required'
696
+ *
697
+ * @param spec - spec.yaml 提取信息(来自 spec-yaml extractor)
698
+ * @param dataKeyField - 数据主键字段名(来自 resolveDataKeyField)
699
+ * @param input - 构建契约的输入参数(来自 BuildCrudContractInput)
700
+ * @returns 数据主键契约对象
701
+ */
702
+ function buildDataKey(spec, dataKeyField, input) {
703
+ const field = spec.fields.find((f) => f.name === dataKeyField);
704
+ return {
705
+ field: dataKeyField,
706
+ label: field?.label ?? dataKeyField,
707
+ availability: dataKeyIsPolicyResolved(spec, input) ? 'derivable' : 'human_required',
708
+ generationStrategy: input.hints?.dataKey?.generationStrategy ?? 'human_required',
709
+ sources: [specSource(spec)]
710
+ };
711
+ }
712
+ function searchComponentFromField(field) {
713
+ return field.controlType === 'mini-combobox' ? 'listbox' : 'input';
714
+ }
715
+ function findFieldByName(pages, fieldName) {
716
+ for (const page of pages) {
717
+ const field = page.fields.find((item) => item.name === fieldName);
718
+ if (field) {
719
+ return field;
720
+ }
721
+ }
722
+ return undefined;
723
+ }
724
+ /**
725
+ * 构建列表页搜索条件契约。
726
+ *
727
+ * 字段结构来自列表页 HTML;listbox 的测试值来自 ModuleHints,避免自动猜测业务枚举。
728
+ *
729
+ * @param pages 上游 HTML 页面提取结果
730
+ * @param dataKeyField 唯一数据锚点字段
731
+ * @param input 构建契约的输入参数
732
+ * @returns 搜索条件契约数组
733
+ */
734
+ function buildSearchConditions(pages, dataKeyField, input) {
735
+ const listPage = pages.find((page) => inferRole(page.pageId) === 'list');
736
+ if (!listPage) {
737
+ return [];
738
+ }
739
+ const hintedConditions = input.hints?.searchConditions;
740
+ const conditionHints = hintedConditions && hintedConditions.length > 0
741
+ ? hintedConditions
742
+ : [{
743
+ field: dataKeyField,
744
+ component: 'input',
745
+ seedValue: input.hints?.autofill?.overrideFields?.find((field) => field.field === dataKeyField)?.value,
746
+ strategy: input.hints?.dataKey?.generationStrategy,
747
+ updatePrefix: '修改'
748
+ }];
749
+ return conditionHints.map((hint) => {
750
+ const listField = listPage.fields.find((field) => field.name === hint.field);
751
+ if (!listField) {
752
+ throw new Error(`无法在列表页搜索区中找到搜索条件字段: ${hint.field}`);
753
+ }
754
+ const component = hint.component ?? searchComponentFromField(listField);
755
+ const sources = [htmlSource(listPage)];
756
+ const createSelections = hint.createSelections?.map((selection) => {
757
+ const selectionField = findFieldByName(pages, selection.field);
758
+ return {
759
+ field: selection.field,
760
+ label: selection.label ?? selectionField?.label ?? selection.field,
761
+ value: selection.value
762
+ };
763
+ });
764
+ return {
765
+ field: listField.name,
766
+ label: listField.label,
767
+ component,
768
+ value: hint.value,
769
+ seedValue: hint.seedValue,
770
+ generationStrategy: hint.strategy,
771
+ updatePrefix: hint.updatePrefix,
772
+ createSelections,
773
+ sources
774
+ };
775
+ });
776
+ }
777
+ /**
778
+ * Toast 消息关键词常量
779
+ *
780
+ * 用于从 Java Action 消息中筛选出与测试断言相关的 toast 消息。
781
+ */
782
+ const TOAST_KEYWORDS = ['成功', '失败', '不可删除', '已存在', '请选择', '不能删除', '无法删除'];
783
+ /**
784
+ * 阻止删除的关键词常量
785
+ *
786
+ * 用于从 Java Action 消息中检测是否存在业务规则阻止删除的提示。
787
+ */
788
+ const BLOCK_DELETE_KEYWORDS = ['不可删除', '不能删除', '无法删除'];
789
+ /**
790
+ * 构建断言契约数组
791
+ *
792
+ * 根据 hints.assertionPolicy 和 Java Action 消息生成测试断言。
793
+ * 断言类型:
794
+ * 1. record_exists - 记录存在性断言(创建后验证)
795
+ * 2. toast_message - toast 消息断言(从 Java Action 消息中提取)
796
+ * 3. record_not_exists - 记录不存在断言(删除后验证)
797
+ *
798
+ * 上游物料关联:
799
+ * - 记录存在性断言:从 spec.yaml 的字段定义推断
800
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/_docs/003-场所窗口信息管理-20260518/spec.yaml`
801
+ * - 提取规则:使用 dataKeyField 作为检查字段,expectedCount=1
802
+ * - 来源:spec.yaml 的字段定义
803
+ *
804
+ * - toast 消息断言:从 Java Action 源码提取消息
805
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/main/java/com/epoint/zwplace/action/GxhZwPlaceAddAction.java:65`
806
+ * - 原文:`addCallbackParam("msg", "保存成功!");`
807
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/main/java/com/epoint/zwplace/action/GxhZwPlaceAddAction.java:54`
808
+ * - 原文:`addCallbackParam("error", "场所名称已存在!");`
809
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/src/main/java/com/epoint/zwplace/action/GxhZwPlaceListAction.java:51`
810
+ * - 原文:`msg = "在用数据不可删除!";`
811
+ * - 提取规则:匹配 `addCallbackParam("msg", ...)` 或 `addCallbackParam("error", ...)` 中的中文消息
812
+ * - 关键词匹配:`TOAST_KEYWORDS = ['成功', '失败', '不可删除', '已存在', '请选择', '不能删除', '无法删除']`
813
+ *
814
+ * - 记录不存在断言:从 hints.assertionPolicy.afterDelete 读取
815
+ * - 来源:`ModuleHints`(测试策略输入)
816
+ * - 条件:`afterDelete === 'record_not_exists'` 时生成
817
+ *
818
+ * 策略消费:
819
+ * - afterCreate: 'record_exists' | 'toast_message' | 'both'(默认 'both')
820
+ * - afterDelete: 'record_not_exists' | 'toast_message' | 'both' | 'human_required'
821
+ *
822
+ * @param spec - spec.yaml 提取信息(来自 spec-yaml extractor)
823
+ * @param dataKeyField - 数据主键字段名(来自 resolveDataKeyField)
824
+ * @param actions - Java Action 提取信息数组(来自 java-action extractor)
825
+ * @param hints - 可选的模块测试策略 hints(来自 ModuleHints)
826
+ * @returns 断言契约数组
827
+ */
828
+ function buildAssertions(spec, dataKeyField, actions, hints) {
829
+ const assertions = [];
830
+ const specRef = specSource(spec);
831
+ // 创建后的记录存在性断言
832
+ const createPolicy = hints?.assertionPolicy?.afterCreate ?? 'both';
833
+ if (createPolicy === 'record_exists' || createPolicy === 'both') {
834
+ assertions.push({
835
+ id: 'record-exists-after-create',
836
+ type: 'record_exists',
837
+ field: dataKeyField,
838
+ expectedCount: 1,
839
+ availability: 'derivable',
840
+ sources: [specRef]
841
+ });
842
+ }
843
+ // 从 Java Action 消息中提取 toast 断言
844
+ const seen = new Set();
845
+ for (const action of actions) {
846
+ const actionSource = { kind: 'java_action', path: action.path, confidence: 'medium' };
847
+ for (const msg of action.messages) {
848
+ if (!TOAST_KEYWORDS.some((kw) => msg.includes(kw)))
849
+ continue;
850
+ if (seen.has(msg))
851
+ continue;
852
+ seen.add(msg);
853
+ assertions.push({
854
+ id: `toast-${assertions.length}`,
855
+ type: 'toast_message',
856
+ expectedMessage: msg,
857
+ availability: 'derivable',
858
+ sources: [actionSource]
859
+ });
860
+ }
861
+ }
862
+ // 删除后的记录不存在断言
863
+ const deletePolicy = hints?.assertionPolicy?.afterDelete;
864
+ if (deletePolicy === 'record_not_exists') {
865
+ assertions.push({
866
+ id: 'record-not-exists-after-delete',
867
+ type: 'record_not_exists',
868
+ field: dataKeyField,
869
+ expectedCount: 0,
870
+ availability: 'derivable',
871
+ sources: [specRef]
872
+ });
873
+ }
874
+ return assertions;
875
+ }
876
+ /**
877
+ * 构建业务规则契约数组
878
+ *
879
+ * 将 spec.yaml 中的业务规则转换为契约格式。
880
+ * 每个业务规则包含:
881
+ * - id: 规则名称
882
+ * - description: 规则描述
883
+ * - availability: 可获取性(固定为 'derivable')
884
+ * - sources: 来源溯源
885
+ *
886
+ * 上游物料关联:
887
+ * - 业务规则:从 spec.yaml 的业务规则定义提取
888
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/_docs/003-场所窗口信息管理-20260518/spec.yaml`
889
+ * - 提取规则:遍历 spec.businessRules 数组
890
+ * - 提取位置:`spec.businessRules[]` 数组中的 `ExtractedSpecBusinessRule` 对象
891
+ * - 来源字段:`rule.name`(规则名称)、`rule.description`(规则描述)
892
+ *
893
+ * @param spec - spec.yaml 提取信息(来自 spec-yaml extractor)
894
+ * @returns 业务规则契约数组
895
+ */
896
+ function buildBusinessRules(spec) {
897
+ return spec.businessRules.map((rule) => ({
898
+ id: rule.name,
899
+ description: rule.description,
900
+ availability: 'derivable',
901
+ sources: [specSource(spec)]
902
+ }));
903
+ }
904
+ /**
905
+ * 构建未解决槽位数组
906
+ *
907
+ * 检查当前输入中缺失的测试策略信息,生成需要人工补充的槽位列表。
908
+ * 检查的槽位包括:
909
+ * 1. dataKey.selectedField - 数据主键字段选择
910
+ * 2. autofill.overrideFields - 表单自动填充覆盖字段
911
+ * 3. dataKey.generationStrategy - 数据生成策略
912
+ * 4. delete.expectedOutcome - 删除预期结果
913
+ *
914
+ * 上游物料关联:
915
+ * - dataKey.selectedField 槽位:从 spec.yaml 的 searchable 字段推断
916
+ * - 文件:`knowledge-project/epoint-web-v9.5.2/_docs/003-场所窗口信息管理-20260518/spec.yaml`
917
+ * - 检查条件:可搜索字段 > 1 且 hints.dataKey.selectedField 未指定
918
+ * - 来源:spec.fields[].searchable=true 的字段列表
919
+ *
920
+ * - autofill.overrideFields 槽位:检查 hints.autofill.overrideFields 是否存在
921
+ * - 来源:`ModuleHints`(测试策略输入)
922
+ * - 检查条件:hints.autofill.overrideFields 未定义
923
+ *
924
+ * - dataKey.generationStrategy 槽位:检查 hints.dataKey.generationStrategy 是否存在
925
+ * - 来源:`ModuleHints`(测试策略输入)
926
+ * - 检查条件:hints.dataKey.generationStrategy 未定义
927
+ *
928
+ * - delete.expectedOutcome 槽位:检查 hints.deletePolicy 是否存在
929
+ * - 来源:`ModuleHints`(测试策略输入)
930
+ * - 检查条件:hints.deletePolicy 未定义
931
+ *
932
+ * 每个槽位包含:
933
+ * - slotId: 槽位标识
934
+ * - reason: 缺失原因说明
935
+ * - requiredBy: 依赖该槽位的契约字段
936
+ * - expectedProvider: 应由谁补充(tester/developer/runtime_config)
937
+ * - suggestedFormat: 建议的 JSON 格式
938
+ *
939
+ * @param spec - spec.yaml 提取信息(来自 spec-yaml extractor)
940
+ * @param actions - Java Action 提取信息数组(来自 java-action extractor,当前未使用,预留)
941
+ * @param input - 构建契约的输入参数(来自 BuildCrudContractInput)
942
+ * @returns 未解决槽位数组
943
+ */
944
+ function buildUnresolvedSlots(spec, _actions, input) {
945
+ const slots = [];
946
+ // 数据主键字段选择槽位
947
+ if (!dataKeyIsPolicyResolved(spec, input)) {
948
+ slots.push({
949
+ slotId: 'dataKey.selectedField',
950
+ reason: '存在多个 searchable 字段或缺少测试策略输入,无法确定哪个字段作为 CRUD 测试数据流转锚点',
951
+ requiredBy: ['dataKey.field', 'flows.search.searchField', 'assertions.record_exists.field'],
952
+ expectedProvider: 'tester',
953
+ suggestedFormat: '{ "dataKey": { "selectedField": "fieldName" } }'
954
+ });
955
+ }
956
+ // 表单自动填充覆盖字段槽位
957
+ if (!input.hints?.autofill?.overrideFields) {
958
+ slots.push({
959
+ slotId: 'autofill.overrideFields',
960
+ reason: '新增/编辑表单中 auto-fill 后需要二次覆盖哪些字段,需要测试人员根据业务场景确认',
961
+ requiredBy: ['flows.create.overrideFields'],
962
+ expectedProvider: 'tester',
963
+ suggestedFormat: '{ "autofill": { "overrideFields": [{ "field": "fieldName", "strategy": "timestamp_prefix" }] } }'
964
+ });
965
+ }
966
+ // 数据生成策略槽位
967
+ if (!input.hints?.dataKey?.generationStrategy) {
968
+ slots.push({
969
+ slotId: 'dataKey.generationStrategy',
970
+ reason: '数据生成策略属于测试策略,不能仅从源码结构推断',
971
+ requiredBy: ['dataKey.generationStrategy'],
972
+ expectedProvider: 'tester',
973
+ suggestedFormat: '{ "dataKey": { "generationStrategy": "timestamp_prefix" } }'
974
+ });
975
+ }
976
+ // 删除预期结果槽位
977
+ if (!input.hints?.deletePolicy) {
978
+ slots.push({
979
+ slotId: 'delete.expectedOutcome',
980
+ reason: '删除流程应验证成功删除、业务阻塞,还是准备可删除草稿数据,需要测试策略确认',
981
+ requiredBy: ['flows.delete.expectedOutcome'],
982
+ expectedProvider: 'tester',
983
+ suggestedFormat: '{ "deletePolicy": "success_delete" | "blocked_by_business_rule" | "prepare_deletable_draft_then_delete" | "human_required" }'
984
+ });
985
+ }
986
+ return slots;
987
+ }
988
+ /**
989
+ * 构建 CRUD 业务模块契约(主入口函数)
990
+ *
991
+ * 将上游 extractor 提取的结构化信息和可选的测试策略 hints
992
+ * 归一化为 crud-business-module/v1 契约。
993
+ *
994
+ * 处理流程:
995
+ * 1. 解析模块标签和按钮标签配置
996
+ * 2. 解析数据主键字段
997
+ * 3. 构建页面契约(list/add/edit/detail)
998
+ * 4. 构建流程契约(create/search/update/delete)
999
+ * 5. 构建断言和业务规则契约
1000
+ * 6. 检查未解决槽位
1001
+ *
1002
+ * @param input - 构建契约的输入参数
1003
+ * @returns 完整的 CRUD 业务模块契约
1004
+ * @throws 当缺少列表页时抛出错误
1005
+ *
1006
+ * @example
1007
+ * ```typescript
1008
+ * import { buildCrudBusinessModuleContract } from './normalizers/crud-contract.js';
1009
+ *
1010
+ * const contract = buildCrudBusinessModuleContract({
1011
+ * moduleId: 'zwplace',
1012
+ * spec: extractedSpec,
1013
+ * codeList: extractedCodeList,
1014
+ * pages: extractedPages,
1015
+ * actions: extractedActions,
1016
+ * hints: {
1017
+ * dataKey: { selectedField: 'placename', generationStrategy: 'timestamp_prefix' },
1018
+ * deletePolicy: 'blocked_by_business_rule'
1019
+ * }
1020
+ * });
1021
+ * ```
1022
+ */
1023
+ export function buildCrudBusinessModuleContract(input) {
1024
+ const { spec, pages, actions, moduleId, codeList } = input;
1025
+ const moduleLabel = spec.module.label || codeList.moduleLabel;
1026
+ const labels = resolveButtonLabels(input.buttonLabels, moduleLabel, input.hints);
1027
+ const dataKeyField = resolveDataKeyField(spec, input);
1028
+ const listPage = pages.find((p) => inferRole(p.pageId) === 'list');
1029
+ const addPage = pages.find((p) => inferRole(p.pageId) === 'add');
1030
+ const editPage = pages.find((p) => inferRole(p.pageId) === 'edit');
1031
+ const detailPage = pages.find((p) => inferRole(p.pageId) === 'detail');
1032
+ if (!listPage) {
1033
+ throw new Error('List page is required to build a CRUD contract');
1034
+ }
1035
+ const contract = {
1036
+ contractVersion: 'crud-business-module/v1',
1037
+ module: {
1038
+ id: moduleId,
1039
+ label: moduleLabel,
1040
+ description: spec.module.description
1041
+ },
1042
+ pages: {
1043
+ list: buildPageContract(listPage, 'list'),
1044
+ ...(addPage ? { add: buildPageContract(addPage, 'add') } : {}),
1045
+ ...(editPage ? { edit: buildPageContract(editPage, 'edit') } : {}),
1046
+ ...(detailPage ? { detail: buildPageContract(detailPage, 'detail') } : {})
1047
+ },
1048
+ dataKey: buildDataKey(spec, dataKeyField, input),
1049
+ searchConditions: buildSearchConditions(pages, dataKeyField, input),
1050
+ flows: {
1051
+ create: buildCreateFlow(pages, labels, dataKeyField, input.hints),
1052
+ search: buildSearchFlow(pages, labels, dataKeyField, input.hints),
1053
+ update: buildUpdateFlow(pages, labels, dataKeyField),
1054
+ delete: buildDeleteFlow(pages, actions, labels, input.hints)
1055
+ },
1056
+ assertions: buildAssertions(spec, dataKeyField, actions, input.hints),
1057
+ businessRules: buildBusinessRules(spec),
1058
+ unresolvedSlots: buildUnresolvedSlots(spec, actions, input)
1059
+ };
1060
+ const agentInferredWorkflows = planAgentInferredWorkflows({
1061
+ contract,
1062
+ actions,
1063
+ hints: input.hints,
1064
+ });
1065
+ return agentInferredWorkflows.length > 0
1066
+ ? { ...contract, agentInferredWorkflows }
1067
+ : contract;
1068
+ }