@epoint-testtech/ep-stage-skill 0.0.4-alpha.0 → 0.0.5

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 (206) hide show
  1. package/SKILL.md +27 -27
  2. package/codex-skill/ep-stage/glue-create-project/SKILL.md +223 -186
  3. package/codex-skill/ep-stage/glue-generate-testcase/SKILL.md +275 -199
  4. package/codex-skill/ep-stage/glue-generate-testcase/examples/observable-testcase.json +40 -40
  5. package/codex-skill/ep-stage/glue-generate-testcase/references/testcase-schema.md +95 -112
  6. package/codex-skill/ep-stage/glue-generate-testcase/references/testcase-tpl.md +72 -0
  7. package/codex-skill/ep-stage/glue-run-test/SKILL.md +292 -249
  8. package/codex-skill/ep-stage/glue-run-test/references/ai-runtime-loop.md +30 -0
  9. package/codex-skill/ep-stage/glue-run-test/references/crud-pipeline.md +132 -145
  10. package/codex-skill/ep-stage/glue-run-test/references/gap-review-protocol.md +44 -43
  11. package/codex-skill/ep-stage/glue-run-test/references/harness-principles.md +46 -46
  12. package/codex-skill/ep-stage/glue-run-test/references/playwright-cli-odav.md +166 -0
  13. package/codex-skill/ep-stage/glue-run-test/scripts/extract-page-elements-eval.mjs +233 -0
  14. package/codex-skill/ep-stage/glue-run-test/scripts/generate-crud-spec.mjs +149 -149
  15. package/codex-skill/ep-stage/page-element-scan/SKILL.md +91 -0
  16. package/codex-skill/ep-stage/page-element-scan/evals/evals.json +37 -0
  17. package/codex-skill/ep-stage/page-element-scan/evals/fixtures/sample-list-page.html +85 -0
  18. package/codex-skill/ep-stage/page-element-scan/references/pagination-controls.md +153 -0
  19. package/codex-skill/ep-stage/page-element-scan/scripts/extract-page-elements-eval.mjs +233 -0
  20. package/codex-skill/ep-stage/page-element-scan/scripts/extract-page-elements.mjs +298 -0
  21. package/codex-skill/ep-stage/page-element-scan/scripts/scan-page.mjs +749 -0
  22. package/codex-skill/ep-stage/recording-to-glue/SKILL.md +28 -28
  23. package/codex-skill/ep-stage/scripts/validate-skill.mjs +98 -95
  24. package/dist/src/cli/generate-testcase.d.ts +18 -51
  25. package/dist/src/cli/generate-testcase.d.ts.map +1 -1
  26. package/dist/src/cli/generate-testcase.js +127 -131
  27. package/dist/src/cli/index.d.ts +11 -2
  28. package/dist/src/cli/index.d.ts.map +1 -1
  29. package/dist/src/cli/index.js +49 -16
  30. package/dist/src/cli/probe.d.ts +2 -0
  31. package/dist/src/cli/probe.d.ts.map +1 -1
  32. package/dist/src/cli/probe.js +239 -13
  33. package/dist/src/cli/run.d.ts +136 -29
  34. package/dist/src/cli/run.d.ts.map +1 -1
  35. package/dist/src/cli/run.js +494 -65
  36. package/dist/src/cli/spec.d.ts +18 -11
  37. package/dist/src/cli/spec.d.ts.map +1 -1
  38. package/dist/src/cli/spec.js +74 -20
  39. package/dist/src/contracts/crud-business-module.d.ts +6 -0
  40. package/dist/src/contracts/crud-business-module.d.ts.map +1 -1
  41. package/dist/src/contracts/glue-contract-v1.d.ts +98 -0
  42. package/dist/src/contracts/glue-contract-v1.d.ts.map +1 -0
  43. package/dist/src/contracts/glue-contract-v1.js +9 -0
  44. package/dist/src/contracts/inference-trace.d.ts +223 -0
  45. package/dist/src/contracts/inference-trace.d.ts.map +1 -0
  46. package/dist/src/contracts/inference-trace.js +12 -0
  47. package/dist/src/contracts/observable-chain.d.ts +4 -0
  48. package/dist/src/contracts/observable-chain.d.ts.map +1 -1
  49. package/dist/src/extractors/code-list-json5.d.ts +192 -0
  50. package/dist/src/extractors/code-list-json5.d.ts.map +1 -0
  51. package/dist/src/extractors/code-list-json5.js +91 -0
  52. package/dist/src/extractors/code-list.d.ts +3 -3
  53. package/dist/src/extractors/code-list.d.ts.map +1 -1
  54. package/dist/src/extractors/code-list.js +46 -11
  55. package/dist/src/extractors/html-page.d.ts +9 -0
  56. package/dist/src/extractors/html-page.d.ts.map +1 -1
  57. package/dist/src/extractors/html-page.js +26 -2
  58. package/dist/src/extractors/prd-extractor.d.ts +70 -0
  59. package/dist/src/extractors/prd-extractor.d.ts.map +1 -0
  60. package/dist/src/extractors/prd-extractor.js +248 -0
  61. package/dist/src/generators/agent-inferred-workflow-script.js +66 -66
  62. package/dist/src/generators/stage-skeleton-script.d.ts +87 -20
  63. package/dist/src/generators/stage-skeleton-script.d.ts.map +1 -1
  64. package/dist/src/generators/stage-skeleton-script.js +656 -238
  65. package/dist/src/index.d.ts +7 -2
  66. package/dist/src/index.d.ts.map +1 -1
  67. package/dist/src/index.js +3 -1
  68. package/dist/src/inference/module-hints-inference.d.ts +30 -0
  69. package/dist/src/inference/module-hints-inference.d.ts.map +1 -0
  70. package/dist/src/inference/module-hints-inference.js +363 -0
  71. package/dist/src/inference/source-analysis.d.ts +26 -0
  72. package/dist/src/inference/source-analysis.d.ts.map +1 -0
  73. package/dist/src/inference/source-analysis.js +258 -0
  74. package/dist/src/normalizers/crud-contract.d.ts +3 -0
  75. package/dist/src/normalizers/crud-contract.d.ts.map +1 -1
  76. package/dist/src/normalizers/crud-contract.js +3 -0
  77. package/dist/src/runtime/ai-runtime-trace.d.ts +88 -0
  78. package/dist/src/runtime/ai-runtime-trace.d.ts.map +1 -0
  79. package/dist/src/runtime/ai-runtime-trace.js +119 -0
  80. package/dist/src/testcase/button-interaction-inference.d.ts +37 -0
  81. package/dist/src/testcase/button-interaction-inference.d.ts.map +1 -0
  82. package/dist/src/testcase/button-interaction-inference.js +385 -0
  83. package/dist/src/testcase/contract-testcase-assembler.d.ts +44 -0
  84. package/dist/src/testcase/contract-testcase-assembler.d.ts.map +1 -0
  85. package/dist/src/testcase/contract-testcase-assembler.js +806 -0
  86. package/dist/src/testcase/prd-button-mapper.d.ts +72 -0
  87. package/dist/src/testcase/prd-button-mapper.d.ts.map +1 -0
  88. package/dist/src/testcase/prd-button-mapper.js +148 -0
  89. package/dist/src/testcase/runtime-context.d.ts +9 -0
  90. package/dist/src/testcase/runtime-context.d.ts.map +1 -0
  91. package/dist/src/testcase/runtime-context.js +1 -0
  92. package/dist/src/testcase/test-access.d.ts +38 -0
  93. package/dist/src/testcase/test-access.d.ts.map +1 -0
  94. package/dist/src/testcase/test-access.js +106 -0
  95. package/dist/src/testcase/testcase-execution-fields.d.ts +10 -0
  96. package/dist/src/testcase/testcase-execution-fields.d.ts.map +1 -0
  97. package/dist/src/testcase/testcase-execution-fields.js +1 -0
  98. package/dist/src/testcase/testcase-generator.d.ts.map +1 -1
  99. package/dist/src/testcase/testcase-generator.js +6 -0
  100. package/dist/src/testcase/testcase-markdown.d.ts +11 -0
  101. package/dist/src/testcase/testcase-markdown.d.ts.map +1 -0
  102. package/dist/src/testcase/testcase-markdown.js +181 -0
  103. package/dist/src/testcase/testcase-skeleton.d.ts +4 -0
  104. package/dist/src/testcase/testcase-skeleton.d.ts.map +1 -1
  105. package/dist/src/testcase/testcase-spec-assembly.d.ts +4 -0
  106. package/dist/src/testcase/testcase-spec-assembly.d.ts.map +1 -1
  107. package/dist/src/testcase/testcase-v1-draft.d.ts +58 -0
  108. package/dist/src/testcase/testcase-v1-draft.d.ts.map +1 -0
  109. package/dist/src/testcase/testcase-v1-draft.js +13 -0
  110. package/dist/src/validation/contract.d.ts +14 -0
  111. package/dist/src/validation/contract.d.ts.map +1 -0
  112. package/dist/src/validation/contract.js +132 -0
  113. package/dist/src/validation/credentials.d.ts +7 -2
  114. package/dist/src/validation/credentials.d.ts.map +1 -1
  115. package/dist/src/validation/credentials.js +15 -4
  116. package/dist/src/validation/index.d.ts +2 -0
  117. package/dist/src/validation/index.d.ts.map +1 -1
  118. package/dist/src/validation/index.js +2 -0
  119. package/dist/src/validation/inference-trace.d.ts +13 -0
  120. package/dist/src/validation/inference-trace.d.ts.map +1 -0
  121. package/dist/src/validation/inference-trace.js +31 -0
  122. package/dist/src/validation/testcase.d.ts +9 -7
  123. package/dist/src/validation/testcase.d.ts.map +1 -1
  124. package/dist/src/validation/testcase.js +181 -34
  125. package/docs/README.md +124 -134
  126. package/docs/mvp-usage-guide.md +298 -298
  127. package/examples/schemeresource-observable-docs/schemeresource.context.md +20 -20
  128. package/examples/schemeresource.module-hints.json +38 -38
  129. package/examples/schemeresource.observable.code_list.md +37 -37
  130. package/examples/zwplace-observable-docs/zwplace.context.md +16 -16
  131. package/examples/zwplace-placecategory-validation.json +29 -29
  132. package/examples/zwplace.module-hints.json +69 -69
  133. package/examples/zwplace.observable.code_list.md +37 -37
  134. package/package.json +2 -2
  135. package/dist/test/cli/extract-contract.test.d.ts +0 -2
  136. package/dist/test/cli/extract-contract.test.d.ts.map +0 -1
  137. package/dist/test/cli/extract-contract.test.js +0 -32
  138. package/dist/test/cli/generate-testcase.test.d.ts +0 -2
  139. package/dist/test/cli/generate-testcase.test.d.ts.map +0 -1
  140. package/dist/test/cli/generate-testcase.test.js +0 -130
  141. package/dist/test/cli/index.test.d.ts +0 -2
  142. package/dist/test/cli/index.test.d.ts.map +0 -1
  143. package/dist/test/cli/index.test.js +0 -93
  144. package/dist/test/cli/run.test.d.ts +0 -2
  145. package/dist/test/cli/run.test.d.ts.map +0 -1
  146. package/dist/test/cli/run.test.js +0 -149
  147. package/dist/test/cli/spec.test.d.ts +0 -2
  148. package/dist/test/cli/spec.test.d.ts.map +0 -1
  149. package/dist/test/cli/spec.test.js +0 -196
  150. package/dist/test/crud-contract.test.d.ts +0 -2
  151. package/dist/test/crud-contract.test.d.ts.map +0 -1
  152. package/dist/test/crud-contract.test.js +0 -819
  153. package/dist/test/gap-inference.test.d.ts +0 -2
  154. package/dist/test/gap-inference.test.d.ts.map +0 -1
  155. package/dist/test/gap-inference.test.js +0 -597
  156. package/dist/test/generalization.test.d.ts +0 -2
  157. package/dist/test/generalization.test.d.ts.map +0 -1
  158. package/dist/test/generalization.test.js +0 -73
  159. package/dist/test/material-inventory.test.d.ts +0 -2
  160. package/dist/test/material-inventory.test.d.ts.map +0 -1
  161. package/dist/test/material-inventory.test.js +0 -141
  162. package/dist/test/observable-chain.test.d.ts +0 -2
  163. package/dist/test/observable-chain.test.d.ts.map +0 -1
  164. package/dist/test/observable-chain.test.js +0 -123
  165. package/dist/test/observable-pipeline.test.d.ts +0 -2
  166. package/dist/test/observable-pipeline.test.d.ts.map +0 -1
  167. package/dist/test/observable-pipeline.test.js +0 -461
  168. package/dist/test/page-structure.test.d.ts +0 -2
  169. package/dist/test/page-structure.test.d.ts.map +0 -1
  170. package/dist/test/page-structure.test.js +0 -45
  171. package/dist/test/scenario-inference.test.d.ts +0 -2
  172. package/dist/test/scenario-inference.test.d.ts.map +0 -1
  173. package/dist/test/scenario-inference.test.js +0 -73
  174. package/dist/test/stage-context.test.d.ts +0 -2
  175. package/dist/test/stage-context.test.d.ts.map +0 -1
  176. package/dist/test/stage-context.test.js +0 -395
  177. package/dist/test/testcase-generator.test.d.ts +0 -2
  178. package/dist/test/testcase-generator.test.d.ts.map +0 -1
  179. package/dist/test/testcase-generator.test.js +0 -276
  180. package/dist/test/testcase-skeleton.test.d.ts +0 -2
  181. package/dist/test/testcase-skeleton.test.d.ts.map +0 -1
  182. package/dist/test/testcase-skeleton.test.js +0 -185
  183. package/dist/test/testcase-spec-assembly.test.d.ts +0 -2
  184. package/dist/test/testcase-spec-assembly.test.d.ts.map +0 -1
  185. package/dist/test/testcase-spec-assembly.test.js +0 -105
  186. package/dist/test/util/credentials.test.d.ts +0 -2
  187. package/dist/test/util/credentials.test.d.ts.map +0 -1
  188. package/dist/test/util/credentials.test.js +0 -64
  189. package/dist/test/util/i18n-testcase.test.d.ts +0 -2
  190. package/dist/test/util/i18n-testcase.test.d.ts.map +0 -1
  191. package/dist/test/util/i18n-testcase.test.js +0 -119
  192. package/dist/test/util/softlink.test.d.ts +0 -2
  193. package/dist/test/util/softlink.test.d.ts.map +0 -1
  194. package/dist/test/util/softlink.test.js +0 -82
  195. package/dist/test/validation/credentials.test.d.ts +0 -2
  196. package/dist/test/validation/credentials.test.d.ts.map +0 -1
  197. package/dist/test/validation/credentials.test.js +0 -72
  198. package/dist/test/validation/projects-index.test.d.ts +0 -2
  199. package/dist/test/validation/projects-index.test.d.ts.map +0 -1
  200. package/dist/test/validation/projects-index.test.js +0 -48
  201. package/dist/test/validation/testcase.test.d.ts +0 -2
  202. package/dist/test/validation/testcase.test.d.ts.map +0 -1
  203. package/dist/test/validation/testcase.test.js +0 -129
  204. package/dist/vitest.config.d.ts +0 -3
  205. package/dist/vitest.config.d.ts.map +0 -1
  206. package/dist/vitest.config.js +0 -7
@@ -0,0 +1,806 @@
1
+ /**
2
+ * JSON5 新协议产线的 contract + testcase 装配器(Task 3)。
3
+ *
4
+ * 职责:
5
+ * 把 {@link ExtractedCodeListJson5}(code_list.json5 解析产出)装配成
6
+ * - {@link EpStageContractV1Draft}:含 scenario.skeletonSlots.crud(形态对齐
7
+ * generators/stage-skeleton-script.ts 的 StageSkeletonCrudFlowSlots)
8
+ * - {@link EpStageTestcaseV1Draft}:10 条 glue cases + uncoveredCandidates
9
+ *
10
+ * 填充策略(brief + 交接文档 §4.2):
11
+ * - queryFields:input/listbox 二分;input 取 entities 中 dataKey 字段作 seed 来源
12
+ * (placename 为 dataKey),listbox 取第一项作 value
13
+ * - frames:code_list 不提供 iframe keyword → 用模块名相关 keyword 占位(014 为 directUrl 模式),
14
+ * 默认策略,不进 unresolvedSlots,在 inference-trace 记账
15
+ * - locators:用 014 草案已确认的框架默认(MiniUI 默认规则 + text=确定 等),不进 unresolvedSlots
16
+ * - assertions.deletePolicy='success_delete'(MiniUI 默认删除成功策略)
17
+ */
18
+ import { extractHtmlPage } from '../extractors/html-page.js';
19
+ import { inferButtonInteractions, } from './button-interaction-inference.js';
20
+ import { existsSync } from 'node:fs';
21
+ import path from 'node:path';
22
+ /**
23
+ * 框架默认 locator 策略(014 草案已确认,交接文档 §4.2)。
24
+ * 这些值不进 unresolvedSlots,在 inference-trace 记账「框架默认」。
25
+ */
26
+ const DEFAULT_LOCATORS = {
27
+ createButton: 'button:has-text("新增")',
28
+ addAutofillTrigger: 'button:has-text("自动填充")',
29
+ addSaveButton: 'button:has-text("保存并关闭")',
30
+ listSearchSubmit: 'button:has-text("查询")',
31
+ listResultValueLocatorTemplate: 'table >> tbody >> tr >> td >> nth=0',
32
+ listEditActionLocatorTemplate: 'button:has-text("修改")',
33
+ editSaveButton: 'button:has-text("保存并关闭")',
34
+ listRowSelectLocatorTemplate: 'table >> tbody >> tr >> input[type="checkbox"]',
35
+ listDeleteButton: 'button:has-text("删除")',
36
+ deleteConfirmButton: 'text=确定',
37
+ };
38
+ /**
39
+ * 用 code_list.pages[list].buttons / rowActions 的真实中文 label 生成精准 xpath locator,
40
+ * 覆盖 MiniUI mini-button + mini-datagrid 的稳定定位;找不到对应按钮时回退到中文默认。
41
+ *
42
+ * xpath 模式对齐 MVP `e2e/glue-code-mvp/src/tests/014-测试需求10-20260622/test-crud-example.spec.ts`
43
+ * 里已在真实系统验证跑通的样板:
44
+ * `xpath=//a[contains(@class,'mini-button')][normalize-space()='<label>']`。
45
+ *
46
+ * gridId 目前固定 'datagrid'(MVP 与 code_list 现网均如此,后续可扩展从
47
+ * code_list.pages[list].dataGrid.id 提取)。
48
+ *
49
+ * @param listPage - code_list.pages[role=list]。
50
+ * @returns 覆盖 StageSkeletonCrudFlowSlots.locators 全部字段的定位表达式。
51
+ */
52
+ function buildCrudLocators(listPage) {
53
+ const gridId = 'datagrid';
54
+ const createLabel = listPage.buttons?.find((b) => b.action === 'create')?.label ?? '新增';
55
+ const deleteLabel = listPage.buttons?.find((b) => b.action === 'delete')?.label ?? '删除选定';
56
+ const editLabel = listPage.rowActions?.find((r) => {
57
+ const anyRow = r;
58
+ return anyRow.action === 'edit' || anyRow.actionName === 'openEdit' || anyRow.label === '修改';
59
+ })?.label ?? '修改';
60
+ const miniButton = (label) => `xpath=//a[contains(@class,'mini-button')][normalize-space()='${label}']`;
61
+ const gridCell = `xpath=//*[@id='${gridId}']//div[text()='{{value}}']`;
62
+ return {
63
+ createButton: miniButton(createLabel),
64
+ addAutofillTrigger: `xpath=//*[@title='默认填充']`,
65
+ addSaveButton: miniButton('保存并关闭'),
66
+ listSearchSubmit: `xpath=//span[text()='搜索']`,
67
+ listResultValueLocatorTemplate: gridCell,
68
+ listEditActionLocatorTemplate: `xpath=//*[@id='${gridId}']//tr[.//div[text()='{{value}}']]//i[@data-tooltip='${editLabel}']`,
69
+ editSaveButton: miniButton('保存并关闭'),
70
+ listRowSelectLocatorTemplate: gridCell,
71
+ listDeleteButton: miniButton(deleteLabel),
72
+ deleteConfirmButton: 'text=确定',
73
+ };
74
+ }
75
+ /**
76
+ * 从 code_list.pages[role=list/add/edit].pageId 提取 iframe src keyword。
77
+ *
78
+ * MVP 样板:list 加 .html 后缀(列表页 URL 直接指向 .html),add/edit 不加(是 modal
79
+ * iframe,src 带 query 参数),三者均含 `gxhzw` 前缀等真实业务 pageId。
80
+ * 若某 role 缺失 pageId,回退到 `<module><role>` 占位。
81
+ */
82
+ function resolveFrameKeywords(codeList) {
83
+ const module = codeList.module;
84
+ const listPageId = codeList.pages.find((p) => p.role === 'list')?.pageId;
85
+ const addPageId = codeList.pages.find((p) => p.role === 'add')?.pageId;
86
+ const editPageId = codeList.pages.find((p) => p.role === 'edit')?.pageId;
87
+ return {
88
+ listIframeSrcKeyword: listPageId ? `${listPageId}.html` : `${module}list`,
89
+ addIframeSrcKeyword: addPageId ?? `${module}add`,
90
+ editIframeSrcKeyword: editPageId ?? `${module}edit`,
91
+ };
92
+ }
93
+ /**
94
+ * 从 code_list 的 entities 推断某 field 是否是 dataKey(业务主键,作 seed 来源)。
95
+ *
96
+ * @param codeList - 解析后的 code_list。
97
+ * @param field - 字段名。
98
+ * @returns 是否 dataKey。
99
+ */
100
+ function isDataKeyField(codeList, field) {
101
+ return (codeList.entities ?? []).some((e) => (e.fields ?? []).some((f) => f.name === field && f.dataKey));
102
+ }
103
+ /**
104
+ * 选择并返回可用的源码根目录。
105
+ *
106
+ * 优先级:sourceRootOverride -> process.env.EP_STAGE_SOURCE_ROOT -> codeList.sourceRoot.root
107
+ */
108
+ function resolveSourceRoot(codeList, sourceRootOverride) {
109
+ const candidates = [
110
+ sourceRootOverride,
111
+ process.env.EP_STAGE_SOURCE_ROOT,
112
+ codeList.sourceRoot?.root,
113
+ ].filter((item) => Boolean(item));
114
+ return candidates[0];
115
+ }
116
+ /**
117
+ * 解析页面 HTML 文件绝对路径。
118
+ */
119
+ function resolvePageHtmlPath(codeList, page, sourceRootOverride) {
120
+ const sourceRoot = resolveSourceRoot(codeList, sourceRootOverride);
121
+ if (!sourceRoot || !page.path)
122
+ return undefined;
123
+ return path.resolve(sourceRoot, page.path);
124
+ }
125
+ /**
126
+ * 解析 pages[*].childGrids 中的 same_as_pages.<role>.childGrids 引用。
127
+ */
128
+ function resolveChildGrids(page, pages) {
129
+ const raw = page.childGrids;
130
+ if (!raw || !Array.isArray(raw) || raw.length === 0)
131
+ return undefined;
132
+ if (raw.every((item) => typeof item === 'string')) {
133
+ const resolved = [];
134
+ for (const ref of raw) {
135
+ const match = ref.match(/^same_as_pages\.([^.]+)\.childGrids$/);
136
+ if (!match)
137
+ continue;
138
+ const role = match[1];
139
+ const targetPage = pages.find((p) => p.role === role);
140
+ if (!targetPage)
141
+ continue;
142
+ const targetGrids = resolveChildGrids(targetPage, pages);
143
+ if (targetGrids)
144
+ resolved.push(...targetGrids);
145
+ }
146
+ return resolved.length > 0 ? resolved : undefined;
147
+ }
148
+ return raw;
149
+ }
150
+ /**
151
+ * 将 HTML 中提取的子表列 editor.type 映射为 skeleton 组件类型。
152
+ * 缺失或未知时默认 input,避免上游 HTML 不规范时直接阻塞整条产线。
153
+ */
154
+ function mapEditorTypeToComponent(editorType) {
155
+ if (editorType === 'combobox')
156
+ return 'listbox';
157
+ return 'input';
158
+ }
159
+ /**
160
+ * 构建子表操作所需的定位表达式。
161
+ */
162
+ function buildChildGridLocators(grid, addAction, deleteAction) {
163
+ const label = grid.label;
164
+ const gridId = grid.id;
165
+ const buttonLocator = (action, defaultLabel) => {
166
+ const buttonLabel = action?.label ?? defaultLabel;
167
+ return `xpath=//div[@label='${label}']//a[contains(@class,'mini-button')][normalize-space()='${buttonLabel}']`;
168
+ };
169
+ return {
170
+ container: `xpath=//div[@label='${label}']`,
171
+ addRowButton: buttonLocator(addAction, '添加'),
172
+ deleteSelectedButton: buttonLocator(deleteAction, '删除选中'),
173
+ grid: `#${gridId}`,
174
+ editWrapInput: '.mini-grid-editwrap input.mini-textbox-input',
175
+ editWrapListboxTrigger: '.mini-grid-editwrap .mini-buttonedit-trigger',
176
+ editWrapListboxInput: '.mini-grid-editwrap input.mini-buttonedit-input',
177
+ rowCheckboxByIndex: `xpath=//*[@id='${gridId}']//tr[contains(@class,'mini-grid-row')][{{rowIndex}}]//td[contains(@class,'checkcolumn')]//span`,
178
+ cellByIndexAndField: `xpath=//*[@id='${gridId}']//tr[contains(@class,'mini-grid-row')][{{rowIndex}}]//td[{{colIndex}}]`,
179
+ cellValueByIndexAndField: `xpath=//*[@id='${gridId}']//tr[contains(@class,'mini-grid-row')][{{rowIndex}}]//td[{{colIndex}}]//div[contains(@class,'mini-grid-cell-inner')]`,
180
+ };
181
+ }
182
+ /**
183
+ * 推断子表操作方式。
184
+ * 优先级:code_list 显式 editMode > HTML 信号(allowCellEdit / editor 列) > 默认 none。
185
+ */
186
+ function inferChildGridEditMode(grid, htmlGrid) {
187
+ if (grid.editMode) {
188
+ return grid.editMode;
189
+ }
190
+ if (!htmlGrid) {
191
+ // HTML 不可用时,根据 code_list 已有信息保守回退:
192
+ // 只要声明了列组件或动作,就默认按 cell_edit 处理,保持与旧行为兼容。
193
+ const hasEditableColumn = grid.columns.some((c) => c.component === 'input' || c.component === 'listbox');
194
+ return hasEditableColumn || (grid.actions && grid.actions.length > 0) ? 'cell_edit' : 'none';
195
+ }
196
+ const hasCellEditSignal = htmlGrid.allowCellEdit === true ||
197
+ htmlGrid.cellEditAction != null ||
198
+ htmlGrid.allowCellSelect === true ||
199
+ htmlGrid.editNextOnEnterKey === true ||
200
+ htmlGrid.oncellbeginedit === true ||
201
+ htmlGrid.onrowdblclick === true;
202
+ const hasEditableColumn = htmlGrid.dataColumns.some((c) => Boolean(c.editorType));
203
+ if (hasCellEditSignal || hasEditableColumn) {
204
+ return 'cell_edit';
205
+ }
206
+ const hasRowEditSignal = htmlGrid.rowActions.some((a) => /openEdit|openDetail|openWindow|edit/i.test(a.onclick ?? a.label)) || htmlGrid.onrowdblclick === true;
207
+ if (hasRowEditSignal) {
208
+ return 'row_button_dialog';
209
+ }
210
+ return 'none';
211
+ }
212
+ /**
213
+ * 从 code_list 的 pages[].childGrids 构建 contract.skeletonSlots.childGrids。
214
+ *
215
+ * 设计取舍:
216
+ * - 优先使用 code_list 中已手写的 component;缺失时读取对应 HTML 的 editor.type 推导。
217
+ * - 对 same_as_pages.<role>.childGrids 引用进行解析。
218
+ * - 同一 gridId 在多个页面出现时只保留第一份(通常是 add 页)。
219
+ */
220
+ function buildChildGridSlots(codeList, sourceRootOverride) {
221
+ const pagesWithChildGrids = codeList.pages.filter((p) => Boolean(p.childGrids));
222
+ if (pagesWithChildGrids.length === 0)
223
+ return undefined;
224
+ const slots = [];
225
+ const seenGridIds = new Set();
226
+ const extractedPageCache = new Map();
227
+ for (const page of pagesWithChildGrids) {
228
+ const childGrids = resolveChildGrids(page, codeList.pages);
229
+ if (!childGrids)
230
+ continue;
231
+ let htmlPage;
232
+ const htmlPath = resolvePageHtmlPath(codeList, page, sourceRootOverride);
233
+ if (htmlPath && existsSync(htmlPath)) {
234
+ try {
235
+ if (!extractedPageCache.has(htmlPath)) {
236
+ extractedPageCache.set(htmlPath, extractHtmlPage(htmlPath));
237
+ }
238
+ htmlPage = extractedPageCache.get(htmlPath);
239
+ }
240
+ catch {
241
+ htmlPage = undefined;
242
+ }
243
+ }
244
+ for (const grid of childGrids) {
245
+ if (seenGridIds.has(grid.id))
246
+ continue;
247
+ seenGridIds.add(grid.id);
248
+ const htmlGrid = htmlPage?.grids.find((g) => g.id === grid.id);
249
+ const columns = grid.columns.map((col) => {
250
+ const htmlColumn = htmlGrid?.dataColumns.find((c) => c.field === col.field);
251
+ const component = col.component ?? mapEditorTypeToComponent(htmlColumn?.editorType);
252
+ return {
253
+ field: col.field,
254
+ label: col.label,
255
+ component,
256
+ required: col.required ?? false,
257
+ modelRef: col.modelRef ?? htmlColumn?.editorAction,
258
+ };
259
+ });
260
+ const addAction = grid.actions?.find((a) => a.actionName === 'addWindow' || a.label.includes('添加')) ?? grid.actions?.[0];
261
+ const deleteAction = grid.actions?.find((a) => a.actionName === 'deleteSelectedWindow' || a.label.includes('删除')) ?? grid.actions?.[1];
262
+ const editMode = inferChildGridEditMode(grid, htmlGrid);
263
+ slots.push({
264
+ gridId: grid.id,
265
+ label: grid.label,
266
+ pageRole: page.role === 'edit' ? 'edit' : 'add',
267
+ editMode,
268
+ columns,
269
+ addRowButtonLabel: addAction?.label,
270
+ deleteSelectedButtonLabel: deleteAction?.label,
271
+ locators: buildChildGridLocators(grid, addAction, deleteAction),
272
+ });
273
+ }
274
+ }
275
+ return slots.length > 0 ? slots : undefined;
276
+ }
277
+ /**
278
+ * 把 list 页 queryFields 映射成 skeletonSlots.crud.searchConditions(input/listbox 二分)。
279
+ *
280
+ * @param codeList - 解析后的 code_list。
281
+ * @param listPage - 列表页 page item。
282
+ * @returns 对齐 StageSkeletonCrudSearchConditionSlot 的槽位数组。
283
+ */
284
+ function buildSearchConditionSlots(codeList, listPage, addOrEditPage) {
285
+ const addFormLabels = new Set((addOrEditPage?.formFields ?? [])
286
+ .filter((f) => typeof f !== 'string')
287
+ .map((f) => f.label));
288
+ return (listPage.queryFields ?? []).map((fieldInfo) => {
289
+ const field = mapQueryFieldToField(fieldInfo);
290
+ const component = normalizeQueryComponent(fieldInfo);
291
+ if (component === 'input') {
292
+ // MiniUI mini-textbox 的 name 属性通常是 `dataBean.<field>` 或省略;只有 label 属性稳定。
293
+ // 用 label 定位 outer div (mini-textbox 容器),再取内部 input.mini-textbox-input。
294
+ const miniTextboxByLabel = `xpath=//div[@label='${fieldInfo.label}']//input[@class='mini-textbox-input']`;
295
+ return {
296
+ component: 'input',
297
+ field,
298
+ label: fieldInfo.label,
299
+ seedValue: isDataKeyField(codeList, field) ? '{{timestamp}}-测试' : '测试数据',
300
+ strategy: 'timestamp_prefix',
301
+ addLocator: miniTextboxByLabel,
302
+ editLocator: miniTextboxByLabel,
303
+ listLocator: miniTextboxByLabel,
304
+ };
305
+ }
306
+ // listbox:真实下拉值来自 dataOptions 字典 (运行时才能确认),assembler 不猜值;
307
+ // 用 skeleton 支持的 valueStrategy=first_non_placeholder + skipValues=['请选择'] 让运行时
308
+ // 选首个有效项。searchAssertion='none' 表示 Read 工作流不对 listbox 校验命中数(因为选到
309
+ // 的值未必对应先前 Create 用的分类,仅校验搜索链路可驱动)。
310
+ // createSelections 仅当该字段同时存在于 add/edit 页 formFields 时才补选;否则为空数组,
311
+ // 避免运行时在新增弹框里找不到该 label 的 mini-listbox 触发器 (如"状态" 是列表页搜索条件
312
+ // 但不在 add 表单里)。
313
+ const fieldInAddForm = addFormLabels.has(fieldInfo.label);
314
+ return {
315
+ component: 'listbox',
316
+ field,
317
+ label: fieldInfo.label,
318
+ valueStrategy: 'first_non_placeholder',
319
+ skipValues: ['请选择'],
320
+ searchAssertion: 'none',
321
+ createSelections: fieldInAddForm
322
+ ? [
323
+ {
324
+ field,
325
+ label: fieldInfo.label,
326
+ valueStrategy: 'first_non_placeholder',
327
+ skipValues: ['请选择'],
328
+ },
329
+ ]
330
+ : [],
331
+ };
332
+ });
333
+ }
334
+ /**
335
+ * 把 code_list 中文 label 映射到契约 field(kebab-ish 英文)。
336
+ * 014 已确认三字段:placename / placecategory / status。
337
+ */
338
+ function mapLabelToField(label) {
339
+ const map = {
340
+ 场所名称: 'placename',
341
+ 场所分类: 'placecategory',
342
+ 状态: 'status',
343
+ };
344
+ return map[label] ?? label;
345
+ }
346
+ /**
347
+ * 从 queryFields 条目推导字段名,优先使用 bind 的 dataBean.xxx。
348
+ */
349
+ function mapQueryFieldToField(fieldInfo) {
350
+ const bindMatch = fieldInfo.bind?.match(/dataBean\.([A-Za-z0-9_]+)/);
351
+ return bindMatch?.[1] ?? mapLabelToField(fieldInfo.label);
352
+ }
353
+ /**
354
+ * 归一化 queryFields 的组件类型,支持上游摘要用 component=mini-textbox/mini-combobox。
355
+ */
356
+ function normalizeQueryComponent(fieldInfo) {
357
+ const raw = fieldInfo.type ?? fieldInfo.component ?? '';
358
+ if (raw === 'input' || raw === 'mini-textbox')
359
+ return 'input';
360
+ return 'listbox';
361
+ }
362
+ /**
363
+ * 生成正式用例编号。
364
+ *
365
+ * @param pageIndex - 待测页面序号,从 1 开始。
366
+ * @param caseIndex - 页面内用例序号,从 1 开始。
367
+ * @returns TC + 两位页面序号 + 三位用例序号。
368
+ */
369
+ function formatCaseId(pageIndex, caseIndex) {
370
+ return `TC${String(pageIndex).padStart(2, '0')}${String(caseIndex).padStart(3, '0')}`;
371
+ }
372
+ /**
373
+ * 从编号上下文中取下一个正式用例编号。
374
+ *
375
+ * @param sequence - 编号上下文。
376
+ * @returns 下一个 caseId。
377
+ */
378
+ function nextCaseId(sequence) {
379
+ const caseId = formatCaseId(sequence.pageIndex, sequence.nextCaseIndex);
380
+ sequence.nextCaseIndex += 1;
381
+ return caseId;
382
+ }
383
+ /**
384
+ * 根据用例标题选择查询主键标签。
385
+ *
386
+ * @param title - 用例标题。
387
+ * @param searchFieldLabels - 列表页查询字段标签集合。
388
+ * @param dataKeyLabel - dataKey 对应的中文标签。
389
+ * @returns testcase.json5.queryKey。
390
+ */
391
+ function pickQueryKeyLabel(label, searchFieldLabels, dataKeyLabel) {
392
+ return searchFieldLabels.find((item) => item === label) ?? dataKeyLabel ?? searchFieldLabels[0] ?? '不适用';
393
+ }
394
+ /**
395
+ * 生成 glue 用例执行字段。
396
+ *
397
+ * @param actionKind - CRUD / Export 动作类型。
398
+ * @param queryKey - 当前用例查询主键。
399
+ * @param searchFieldLabels - 列表页查询字段标签集合。
400
+ * @param dataKeyLabel - dataKey 对应的中文标签。
401
+ * @returns testcase 执行字段。
402
+ */
403
+ function buildGlueExecutionFields(actionKind, queryKeyLabel, queryComponent, queryKeyInForm, searchFieldLabels, dataKeyLabel) {
404
+ const queryKey = actionKind === 'export'
405
+ ? pickQueryKeyLabel(dataKeyLabel ?? searchFieldLabels[0] ?? '不适用', searchFieldLabels, dataKeyLabel)
406
+ : pickQueryKeyLabel(queryKeyLabel, searchFieldLabels, dataKeyLabel);
407
+ const smokeEntry = '1. 完成系统登录并进入目标待测页面。';
408
+ const createQueryKeyStep = queryComponent === 'listbox'
409
+ ? `3. 在${queryKey}字段选择首个有效下拉选项(跳过“请选择”)。`
410
+ : `3. 将${queryKey}字段填写为带时间戳的测试值。`;
411
+ const updateQueryKeyStep = queryComponent === 'listbox'
412
+ ? `3. 在编辑表单中修改可编辑字段;若需要修改${queryKey},选择另一个有效下拉选项。`
413
+ : `3. 在编辑表单中修改可编辑字段;若${queryKey}可编辑,则写入新的带时间戳测试值。`;
414
+ if (actionKind === 'create') {
415
+ return {
416
+ queryKey,
417
+ precondition: smokeEntry,
418
+ steps: [
419
+ '1. 点击{{新增按钮}}。',
420
+ '2. 在弹出的新增表单中填写必填字段。',
421
+ createQueryKeyStep,
422
+ '4. 点击{{保存按钮}}完成新增。',
423
+ ].join(' '),
424
+ assertion: `保存后返回{{列表页}};使用${queryKey}搜索新增时填写的数据,列表应只返回一条匹配记录。`,
425
+ };
426
+ }
427
+ if (actionKind === 'read') {
428
+ if (!queryKeyInForm) {
429
+ return {
430
+ queryKey,
431
+ precondition: smokeEntry,
432
+ steps: [
433
+ `1. 在列表页${queryKey}查询条件中输入或选择一个有效查询值。`,
434
+ '2. 点击{{查询按钮}}。',
435
+ ].join(' '),
436
+ assertion: '查询动作应完成,列表刷新或查询请求被触发;本用例不强制断言列表数据命中数量。',
437
+ };
438
+ }
439
+ return {
440
+ queryKey,
441
+ precondition: `${smokeEntry} 2. 已基于${queryKey}完成一条测试数据新增。`,
442
+ steps: [
443
+ `1. 在列表页${queryKey}查询条件中输入或选择前置数据的查询值。`,
444
+ '2. 点击{{查询按钮}}。',
445
+ ].join(' '),
446
+ assertion: `列表应只返回一条匹配记录,且记录的${queryKey}值与前置数据一致。`,
447
+ };
448
+ }
449
+ if (actionKind === 'update') {
450
+ return {
451
+ queryKey,
452
+ precondition: `${smokeEntry} 2. 已基于${queryKey}完成一条测试数据新增。`,
453
+ steps: [
454
+ `1. 在列表页按${queryKey}搜索前置数据。`,
455
+ '2. 定位搜索结果中的目标记录并点击{{修改按钮}}。',
456
+ updateQueryKeyStep,
457
+ '4. 点击{{保存按钮}}完成修改。',
458
+ ].join(' '),
459
+ assertion: `修改后返回{{列表页}};使用修改后的${queryKey}或可断言字段重新查询,列表应只返回一条匹配记录。`,
460
+ };
461
+ }
462
+ if (actionKind === 'delete') {
463
+ return {
464
+ queryKey,
465
+ precondition: `${smokeEntry} 2. 已基于${queryKey}完成一条测试数据新增。`,
466
+ steps: [
467
+ `1. 在列表页按${queryKey}搜索前置数据。`,
468
+ '2. 勾选搜索结果中的目标记录。',
469
+ '3. 点击{{删除按钮}}。',
470
+ '4. 在确认弹窗中点击{{确认按钮}}。',
471
+ ].join(' '),
472
+ assertion: `删除后再次按${queryKey}搜索该数据,列表应无匹配记录。`,
473
+ };
474
+ }
475
+ return {
476
+ queryKey,
477
+ precondition: smokeEntry,
478
+ steps: [
479
+ '1. 在列表页点击{{导出按钮}}。',
480
+ '2. 在导出弹窗中选择导出范围。',
481
+ '3. 点击弹窗中的{{导出确认按钮}}。',
482
+ ].join(' '),
483
+ assertion: '系统应触发导出流程;若当前环境可观测下载结果,则校验导出文件生成,否则校验导出弹窗或导出请求已被触发。',
484
+ };
485
+ }
486
+ /**
487
+ * 装配 10 条 glue cases(placename/placecategory 各 CRUD + status.read + export.all),
488
+ * 与交接文档 §4.3 一致。
489
+ *
490
+ * @param moduleId - 模块标识(用作 caseId 前缀)。
491
+ * @param searchFieldLabels - 列表页查询字段标签集合。
492
+ * @param dataKeyLabel - dataKey 对应的中文标签。
493
+ * @returns cases 数组(generationStrategy 全 glue)。
494
+ */
495
+ function buildGlueCases(moduleName, searchFields, formFieldLabels, sequence, dataKeyLabel) {
496
+ const searchFieldLabels = searchFields.map((fieldInfo) => fieldInfo.label);
497
+ const componentForLabel = (label) => searchFields.find((fieldInfo) => fieldInfo.label === label)?.component ?? 'input';
498
+ const queryKeyInForm = (label) => formFieldLabels.includes(label);
499
+ const mk = (actionKind, queryKeyLabel, coverageStatus) => ({
500
+ caseId: nextCaseId(sequence),
501
+ title: actionKind === 'export'
502
+ ? `${moduleName} export`
503
+ : `${moduleName} ${actionKind}(${queryKeyLabel})`,
504
+ generationStrategy: 'glue',
505
+ coverageStatus,
506
+ reviewStatus: 'confirmed',
507
+ ...buildGlueExecutionFields(actionKind, queryKeyLabel, componentForLabel(queryKeyLabel), queryKeyInForm(queryKeyLabel), searchFieldLabels, dataKeyLabel),
508
+ });
509
+ return [
510
+ mk('create', '场所名称', 'covered'),
511
+ mk('read', '场所名称', 'covered'),
512
+ mk('update', '场所名称', 'covered'),
513
+ mk('delete', '场所名称', 'covered'),
514
+ mk('create', '场所分类', 'covered'),
515
+ mk('read', '场所分类', 'partial'),
516
+ mk('update', '场所分类', 'covered'),
517
+ mk('delete', '场所分类', 'covered'),
518
+ mk('read', '状态', 'partial'),
519
+ mk('export', dataKeyLabel ?? searchFieldLabels[0] ?? '不适用', 'covered'),
520
+ ];
521
+ }
522
+ /**
523
+ * 为子表(childGrids)装配 glue cases。
524
+ *
525
+ * 每个子表生成 4 条用例:新增行、编辑行、删除行、必填校验。
526
+ * 删除行标记为 gap:子表行删除通常依赖 confirm 弹窗与后端接口,运行态稳定性差,
527
+ * 需要真实环境验证后才能提升为 covered。
528
+ *
529
+ * @param childGrids - 子表槽位清单。
530
+ * @param firstRole - 首个角色(用作前置条件)。
531
+ * @param sequence - 用例编号上下文。
532
+ * @returns cases 数组(generationStrategy 全 glue)。
533
+ */
534
+ function buildChildGridCases(childGrids, firstRole, sequence) {
535
+ const smokeEntry = `1. 使用角色:${firstRole}。 2. 完成系统登录并进入目标新增页面。`;
536
+ return childGrids.flatMap((grid) => {
537
+ const label = grid.label;
538
+ const addRowButtonLabel = grid.addRowButtonLabel ?? '添加';
539
+ const deleteSelectedButtonLabel = grid.deleteSelectedButtonLabel ?? '删除选中';
540
+ const requiredColumns = grid.columns.filter((c) => c.required);
541
+ const requiredLabels = requiredColumns.map((c) => c.label).join('、') || '必填字段';
542
+ const allColumnLabels = grid.columns.map((c) => c.label).join('、');
543
+ const canAddRow = Boolean(grid.addRowButtonLabel);
544
+ const canDeleteRow = Boolean(grid.deleteSelectedButtonLabel);
545
+ const canEditCell = grid.editMode === 'cell_edit';
546
+ const hasRequiredColumn = requiredColumns.length > 0;
547
+ const mk = (title, coverageStatus, precondition, steps, assertion) => ({
548
+ caseId: nextCaseId(sequence),
549
+ title,
550
+ generationStrategy: 'glue',
551
+ coverageStatus,
552
+ reviewStatus: 'confirmed',
553
+ queryKey: '不适用',
554
+ precondition,
555
+ steps,
556
+ assertion,
557
+ });
558
+ const cases = [];
559
+ if (canAddRow) {
560
+ cases.push(mk(`${label}-新增行`, 'covered', smokeEntry, `1. 在新增页定位到「${label}」子表。 2. 点击子表工具栏的「${addRowButtonLabel}」按钮。 3. 在新增行中填写 ${allColumnLabels} 等字段。 4. 点击主表「保存并关闭」。 5. 观察保存结果。`, `保存后主表成功提交,子表新增数据随主表持久化;重新打开详情或编辑页可看到新增的行。`));
561
+ }
562
+ if (canEditCell) {
563
+ cases.push(mk(`${label}-编辑行`, 'covered', `${smokeEntry} 3. 子表中已存在至少一行数据。`, `1. 在新增页定位到「${label}」子表。 2. 选中或双击待编辑行进入编辑状态。 3. 修改 ${allColumnLabels} 等字段。 4. 点击主表「保存并关闭」。 5. 观察保存结果。`, `保存后子表修改内容持久化;重新打开详情或编辑页可看到更新后的值。`));
564
+ }
565
+ if (canDeleteRow) {
566
+ cases.push(mk(`${label}-删除行`, 'covered', `${smokeEntry} 3. 子表中已存在至少一行数据。`, `1. 在新增页定位到「${label}」子表。 2. 勾选要删除的数据行。 3. 点击子表工具栏的「${deleteSelectedButtonLabel}」按钮。 4. 在确认弹窗中点击「确定」。 5. 观察并记录最终反馈。`, `点击「${deleteSelectedButtonLabel}」并确认后,子表对应行应被删除;重新加载页面后该行数据不再存在。`));
567
+ }
568
+ if (canEditCell && hasRequiredColumn) {
569
+ cases.push(mk(`${label}-必填校验`, 'covered', smokeEntry, `1. 在新增页点击「${addRowButtonLabel}」新增一行。 2. 将 ${requiredLabels} 留空或选择空值。 3. 点击主表「保存并关闭」。 4. 观察校验提示。`, `保存时系统应阻断提交并提示「第N行${requiredLabels}不能为空!」等校验信息。`));
570
+ }
571
+ return cases;
572
+ });
573
+ }
574
+ /**
575
+ * 把 contract.scenario.gapCandidates 映射为 testcase.uncoveredCandidates。
576
+ * 当前仅处理控件类 custom 按钮,按 code_list 中真实的 label / customAction / backendMethod
577
+ * 生成精确步骤、前置条件与断言;若 button-interaction-inference 成功识别交互模式,
578
+ * 则使用对应的精确步骤/断言,否则回退到通用描述。
579
+ *
580
+ * @param gapCandidates - contract 中已推导的控件类 gap 候选。
581
+ * @param firstRole - 首个角色(用作 requiredRole)。
582
+ * @param sequence - 用例编号上下文。
583
+ * @param codeList - 解析后的 code_list,用于反查按钮原始元数据。
584
+ * @param buttonInferences - 按钮交互推理结果映射(label -> inference)。
585
+ */
586
+ function buildUncoveredCandidates(gapCandidates, firstRole, sequence, codeList, buttonInferences) {
587
+ const listPage = codeList.pages.find((p) => p.role === 'list');
588
+ return gapCandidates.map((candidate) => {
589
+ // 从 code_list.pages[list].buttons 中反查该 custom 按钮的元数据。
590
+ const btn = (listPage?.buttons ?? []).find((b) => {
591
+ const anyB = b;
592
+ return anyB.action === 'custom'
593
+ && (anyB.label === candidate.label || anyB.customAction === candidate.gapId);
594
+ });
595
+ const label = btn?.label ?? candidate.label;
596
+ const customAction = btn?.customAction ?? candidate.gapId;
597
+ // 优先使用前端行为推理结果;若不可用则回退到通用描述。
598
+ const inference = buttonInferences.get(label);
599
+ const steps = inference?.steps ?? [
600
+ `1. 在列表页定位并点击「${label}」按钮。`,
601
+ `2. 观察并记录页面反馈(如弹窗、提示信息、列表刷新等)。`,
602
+ `3. 确认页面状态与按钮功能预期一致。`,
603
+ ].join(' ');
604
+ const assertion = inference?.assertion
605
+ ?? `点击「${label}」按钮后,页面应出现与该功能对应的可见响应(如提示信息、弹窗确认或数据刷新)。`;
606
+ return {
607
+ caseId: nextCaseId(sequence),
608
+ label,
609
+ generationStrategy: 'ai',
610
+ coverageStatus: 'gap',
611
+ requiredRole: firstRole,
612
+ businessIntent: `覆盖列表页「${label}」按钮的控件功能(customAction=${customAction})。`,
613
+ assertionExpectation: assertion,
614
+ queryKey: '不适用',
615
+ precondition: `1. 使用角色:${firstRole}。 2. 完成系统登录并进入目标待测页面。`,
616
+ steps,
617
+ assertion,
618
+ };
619
+ });
620
+ }
621
+ /**
622
+ * 按 code_list 里的系统与角色事实生成 testcase.testAccess 占位。
623
+ *
624
+ * @param codeList - 解析后的 code_list。
625
+ * @returns testcase.testAccess 占位;明文账号密码只进入 credentials.json5,不进入 testcase。
626
+ */
627
+ function buildTestAccess(codeList) {
628
+ return {
629
+ credentialRef: 'credentials.json5',
630
+ requiredRoles: listRequiredRoles(codeList),
631
+ };
632
+ }
633
+ /**
634
+ * 从 code_list 提取用例需要的角色清单。
635
+ *
636
+ * @param codeList - 解析后的 code_list。
637
+ * @returns 非空角色名数组。
638
+ */
639
+ function listRequiredRoles(codeList) {
640
+ const roles = codeList.roles.length > 0
641
+ ? codeList.roles.map((role) => role.role || role.label || '业务角色待确认')
642
+ : codeList.requirement?.businessRoles?.length
643
+ ? codeList.requirement.businessRoles.map((role) => role.label)
644
+ : (codeList.testAccess?.systems ?? []).flatMap((system) => system.roles.map((role) => role.role || '业务角色待确认'));
645
+ return roles.length > 0 ? roles : ['业务角色待确认'];
646
+ }
647
+ /**
648
+ * 解析待测列表页直达地址。
649
+ *
650
+ * @param input - 装配器输入。
651
+ * @param listPage - 列表页物料。
652
+ * @returns directUrl 或 undefined。
653
+ */
654
+ function resolveDirectUrl(input, listPage) {
655
+ return input.directUrl ?? listPage.directUrl;
656
+ }
657
+ /**
658
+ * 装配 contract + testcase + inference-trace 账本。
659
+ *
660
+ * @param input - 含 code_list 解析产出的输入。
661
+ * @returns contract / testcase / inferenceTrace。
662
+ */
663
+ export function assembleJson5ContractAndTestcase(input) {
664
+ const { codeList } = input;
665
+ const module = codeList.module;
666
+ const moduleName = codeList.moduleName;
667
+ const listPage = codeList.pages.find((p) => p.role === 'list');
668
+ if (!listPage) {
669
+ throw new Error('code_list.json5 缺少 role=list 的列表页');
670
+ }
671
+ const firstRole = codeList.requirement?.businessRoles?.[0]?.label ??
672
+ codeList.roles[0]?.label ??
673
+ codeList.roles[0]?.role ??
674
+ '业务角色待确认';
675
+ const directUrl = resolveDirectUrl(input, listPage);
676
+ const searchFields = (listPage.queryFields ?? []).map((fieldInfo) => ({
677
+ field: mapQueryFieldToField(fieldInfo),
678
+ label: fieldInfo.label,
679
+ component: normalizeQueryComponent(fieldInfo),
680
+ }));
681
+ const searchFieldLabels = searchFields.map((fieldInfo) => fieldInfo.label);
682
+ const addOrEditPage = codeList.pages.find((p) => p.role === 'add') ?? codeList.pages.find((p) => p.role === 'edit');
683
+ const formFieldLabels = (addOrEditPage?.formFields ?? [])
684
+ .filter((fieldInfo) => typeof fieldInfo !== 'string')
685
+ .map((fieldInfo) => fieldInfo.label);
686
+ const dataKeyLabel = (listPage.queryFields ?? []).find((fieldInfo) => {
687
+ const field = mapQueryFieldToField(fieldInfo);
688
+ return isDataKeyField(codeList, field);
689
+ })?.label;
690
+ // 从 code_list.businessRules 里找 scope 含 'delete' 的规则(如 delete.onlyDraftAllowed
691
+ // "非 DRAFT 状态删除时被阻断"),命中则映射为 blocked_by_business_rule;否则 success_delete。
692
+ // assertionHint 直接作为 skeleton 的阻断消息断言值。
693
+ const deleteRule = (codeList.businessRules ?? []).find((r) => {
694
+ const anyRule = r;
695
+ return Array.isArray(anyRule.scope) && anyRule.scope.includes('delete');
696
+ });
697
+ const deletePolicy = deleteRule ? 'blocked_by_business_rule' : 'success_delete';
698
+ const blockedDeleteMessage = deleteRule
699
+ ? (deleteRule.assertionHint
700
+ ?? deleteRule.assertion)
701
+ : undefined;
702
+ const skeletonSlotsCrud = {
703
+ moduleId: module,
704
+ searchConditions: buildSearchConditionSlots(codeList, listPage, addOrEditPage),
705
+ frames: resolveFrameKeywords(codeList),
706
+ locators: buildCrudLocators(listPage),
707
+ assertions: {
708
+ deletePolicy,
709
+ blockedDeleteMessage,
710
+ },
711
+ };
712
+ const childGridSlots = buildChildGridSlots(codeList, input.sourceRoot);
713
+ // 动态推导 gapCandidates:当前阶段只保留控件类 custom 按钮。
714
+ // 状态流转、子表、详情、统计看板/移动端等 PRD-only/业务类场景暂不进 gapCandidates,
715
+ // 后续可通过 code_list.gapHints 让业务人工声明后再扩展。
716
+ const gapCandidates = [];
717
+ (listPage.buttons ?? [])
718
+ .filter((b) => b.action === 'custom')
719
+ .forEach((btn) => {
720
+ const anyBtn = btn;
721
+ gapCandidates.push({
722
+ gapId: anyBtn.customAction ?? anyBtn.label ?? 'custom',
723
+ label: anyBtn.label ?? anyBtn.customAction ?? 'custom',
724
+ generationStrategy: 'ai',
725
+ });
726
+ });
727
+ const contract = {
728
+ schemaVersion: 'ep-stage-contract/v1-draft',
729
+ contractId: `${module}-contract`,
730
+ contractKind: 'crud_export',
731
+ base: {
732
+ module: { id: module, label: moduleName },
733
+ navigation: {
734
+ entryMode: 'directUrl',
735
+ directUrl,
736
+ },
737
+ credentials: { source: 'credentials.json5' },
738
+ },
739
+ scenario: {
740
+ kind: 'crud_export',
741
+ searchFields,
742
+ skeletonSlots: {
743
+ crud: skeletonSlotsCrud,
744
+ export: { moduleId: module, label: '导出', mode: 'all' },
745
+ childGrids: childGridSlots,
746
+ },
747
+ gapCandidates,
748
+ },
749
+ unresolvedSlots: [], // 框架默认策略都已确认,不进 unresolvedSlots
750
+ };
751
+ const caseIdSequence = { pageIndex: 1, nextCaseIndex: 1 };
752
+ const cases = buildGlueCases(moduleName, searchFields, formFieldLabels, caseIdSequence, dataKeyLabel);
753
+ const childGridCases = childGridSlots ? buildChildGridCases(childGridSlots, firstRole, caseIdSequence) : [];
754
+ cases.push(...childGridCases);
755
+ const buttonInferences = inferButtonInteractions(codeList, {
756
+ sourceRootOverride: input.sourceRoot,
757
+ });
758
+ const uncoveredCandidates = buildUncoveredCandidates(gapCandidates, firstRole, caseIdSequence, codeList, buttonInferences);
759
+ const testcase = {
760
+ schemaVersion: 'ep-stage-testcase/v1-draft',
761
+ testcaseId: `${module}-testcase`,
762
+ contractRef: '.ep-stage/contract.json5',
763
+ scenario: 'crud_export',
764
+ moduleId: module,
765
+ moduleName,
766
+ testAccess: buildTestAccess(codeList),
767
+ cases,
768
+ uncoveredCandidates,
769
+ };
770
+ const inferenceTrace = {
771
+ schemaVersion: 'ep-stage-inference-trace/v1-draft',
772
+ codeListParseMode: 'json5',
773
+ needsHumanReview: true,
774
+ skeletonSlotsPolicy: {
775
+ frames: {
776
+ source: 'framework-default',
777
+ reason: 'code_list.json5 不提供 iframe keyword,014 为 directUrl 模式,用模块名占位',
778
+ },
779
+ locators: {
780
+ source: 'framework-default',
781
+ reason: '014 草案已确认 MiniUI 默认规则 + text=确定 确认按钮 + addAutofillTrigger 默认能力',
782
+ },
783
+ assertions: {
784
+ source: 'framework-default',
785
+ reason: "deletePolicy='success_delete'(MiniUI 默认删除成功策略)",
786
+ },
787
+ queryFields: {
788
+ source: 'code-list-derived',
789
+ reason: 'placename 为 dataKey 用 timestamp_prefix;listbox 取首项作 value',
790
+ },
791
+ childGrids: {
792
+ source: 'html-derived',
793
+ reason: '从 add/edit 页 HTML 的 datagrid 列 editor.type 推导 component;locators 按 MiniUI 子表默认规则生成',
794
+ },
795
+ },
796
+ unresolvedSlotsCount: 0,
797
+ customButtonInferences: Array.from(buttonInferences.values()).map((inf) => ({
798
+ label: inf.label,
799
+ pattern: inf.pattern,
800
+ functionName: inf.functionName,
801
+ fallbackReason: inf.fallbackReason,
802
+ evidence: inf.evidence,
803
+ })),
804
+ };
805
+ return { contract, testcase, inferenceTrace };
806
+ }