@epoint-testtech/ep-stage-skill 0.0.15 → 0.0.17

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 (43) hide show
  1. package/codex-skill/ep-stage/code-list-generate/SKILL.md +1 -1
  2. package/codex-skill/ep-stage/glue-create-project/SKILL.md +1 -1
  3. package/codex-skill/ep-stage/glue-generate-testcase/SKILL.md +7 -6
  4. package/codex-skill/ep-stage/glue-run-test/SKILL.md +100 -38
  5. package/codex-skill/ep-stage/glue-run-test/references/ai-runtime-loop.md +15 -9
  6. package/codex-skill/ep-stage/glue-run-test/references/crud-pipeline.md +10 -3
  7. package/codex-skill/ep-stage/glue-run-test/references/gap-review-protocol.md +11 -0
  8. package/codex-skill/ep-stage/glue-run-test/references/playwright-cli-odav.md +13 -2
  9. package/codex-skill/ep-stage/page-element-scan/SKILL.md +92 -91
  10. package/codex-skill/ep-stage/scripts/validate-skill.mjs +4 -1
  11. package/dist/src/cli/calibrate.d.ts +75 -0
  12. package/dist/src/cli/calibrate.d.ts.map +1 -0
  13. package/dist/src/cli/calibrate.js +318 -0
  14. package/dist/src/cli/demote.d.ts +53 -0
  15. package/dist/src/cli/demote.d.ts.map +1 -0
  16. package/dist/src/cli/demote.js +111 -0
  17. package/dist/src/cli/index.d.ts +5 -3
  18. package/dist/src/cli/index.d.ts.map +1 -1
  19. package/dist/src/cli/index.js +45 -6
  20. package/dist/src/cli/run-report.d.ts +6 -0
  21. package/dist/src/cli/run-report.d.ts.map +1 -1
  22. package/dist/src/cli/run-report.js +161 -147
  23. package/dist/src/cli/run.d.ts +51 -0
  24. package/dist/src/cli/run.d.ts.map +1 -1
  25. package/dist/src/cli/run.js +233 -31
  26. package/dist/src/contracts/glue-contract-v1.d.ts +37 -1
  27. package/dist/src/contracts/glue-contract-v1.d.ts.map +1 -1
  28. package/dist/src/extractors/code-list-json5.d.ts +29 -0
  29. package/dist/src/extractors/code-list-json5.d.ts.map +1 -1
  30. package/dist/src/generators/stage-skeleton-script.d.ts +30 -1
  31. package/dist/src/generators/stage-skeleton-script.d.ts.map +1 -1
  32. package/dist/src/generators/stage-skeleton-script.js +74 -11
  33. package/dist/src/runtime/ai-runtime-trace.d.ts +6 -0
  34. package/dist/src/runtime/ai-runtime-trace.d.ts.map +1 -1
  35. package/dist/src/testcase/contract-testcase-assembler.d.ts +23 -4
  36. package/dist/src/testcase/contract-testcase-assembler.d.ts.map +1 -1
  37. package/dist/src/testcase/contract-testcase-assembler.js +204 -27
  38. package/dist/src/testcase/query-component-map.d.ts +51 -0
  39. package/dist/src/testcase/query-component-map.d.ts.map +1 -0
  40. package/dist/src/testcase/query-component-map.js +64 -0
  41. package/dist/src/validation/contract.d.ts.map +1 -1
  42. package/dist/src/validation/contract.js +45 -3
  43. package/package.json +1 -1
@@ -334,6 +334,13 @@ function renderOptionalStringProperty(key, value, level) {
334
334
  return '';
335
335
  return `${' '.repeat(level)}${key}: ${singleQuoted(value)},\n`;
336
336
  }
337
+ /** 渲染可选字符串数组属性(如 gridColumns);缺省/空数组不渲染。 */
338
+ function renderStringArrayProperty(key, values, level) {
339
+ if (!values || values.length === 0)
340
+ return '';
341
+ const items = values.map((value) => singleQuoted(value)).join(', ');
342
+ return `${' '.repeat(level)}${key}: [${items}],\n`;
343
+ }
337
344
  function renderListboxSelections(selections, level) {
338
345
  if (!selections) {
339
346
  return '';
@@ -367,6 +374,50 @@ function renderListboxSelections(selections, level) {
367
374
  function renderSearchCondition(condition, level) {
368
375
  const indent = ' '.repeat(level);
369
376
  const innerIndent = ' '.repeat(level + 4);
377
+ if (condition.component === 'datepicker') {
378
+ const lines = [
379
+ `${innerIndent}component: 'datepicker',`,
380
+ `${innerIndent}field: ${singleQuoted(condition.field)},`,
381
+ `${innerIndent}label: ${singleQuoted(condition.label)},`,
382
+ ];
383
+ if (condition.listLocator !== undefined) {
384
+ lines.push(`${innerIndent}listLocator: ${locatorLiteral(condition.listLocator)},`);
385
+ }
386
+ if (condition.searchText !== undefined) {
387
+ lines.push(`${innerIndent}searchText: ${singleQuoted(condition.searchText)},`);
388
+ }
389
+ if (condition.triggerSearch !== undefined) {
390
+ lines.push(`${innerIndent}triggerSearch: ${condition.triggerSearch},`);
391
+ }
392
+ if (condition.searchAssertion !== undefined) {
393
+ lines.push(`${innerIndent}searchAssertion: ${singleQuoted(condition.searchAssertion)},`);
394
+ }
395
+ return `${indent}{
396
+ ${lines.join('\n')}
397
+ ${indent}}`;
398
+ }
399
+ if (condition.component === 'tree') {
400
+ const lines = [
401
+ `${innerIndent}component: 'tree',`,
402
+ `${innerIndent}field: ${singleQuoted(condition.field)},`,
403
+ `${innerIndent}label: ${singleQuoted(condition.label)},`,
404
+ ];
405
+ if (condition.treeLocator !== undefined) {
406
+ lines.push(`${innerIndent}treeLocator: ${locatorLiteral(condition.treeLocator)},`);
407
+ }
408
+ if (condition.nodeStrategy !== undefined) {
409
+ lines.push(`${innerIndent}nodeStrategy: ${singleQuoted(condition.nodeStrategy)},`);
410
+ }
411
+ if (condition.searchOnSelect !== undefined) {
412
+ lines.push(`${innerIndent}searchOnSelect: ${condition.searchOnSelect},`);
413
+ }
414
+ if (condition.searchAssertion !== undefined) {
415
+ lines.push(`${innerIndent}searchAssertion: ${singleQuoted(condition.searchAssertion)},`);
416
+ }
417
+ return `${indent}{
418
+ ${lines.join('\n')}
419
+ ${indent}}`;
420
+ }
370
421
  if (condition.component === 'listbox') {
371
422
  const lines = [
372
423
  `${innerIndent}component: 'listbox',`,
@@ -433,8 +484,8 @@ ${renderOptionalStringProperty('deleteConfirmButton', slots.locators.deleteConfi
433
484
  assertions: {
434
485
  deletePolicy: ${singleQuoted(slots.assertions.deletePolicy)},
435
486
  ${renderOptionalStringProperty('blockedDeleteMessage', slots.assertions.blockedDeleteMessage, 12)}
436
- ${slots.assertions.conditionalDelete ? ' conditionalDelete: true,\n' : ''} },
437
- }`;
487
+ ${slots.assertions.conditionalDelete ? ' conditionalDelete: true,\n' : ''}${renderOptionalStringProperty('searchResponseUrlKeyword', slots.assertions.searchResponseUrlKeyword, 12)} },
488
+ ${renderStringArrayProperty('gridColumns', slots.gridColumns, 8)} }`;
438
489
  }
439
490
  function renderChildGridColumns(columns, level) {
440
491
  const indent = ' '.repeat(level);
@@ -793,13 +844,20 @@ function renderCrudCaseTests(cases, context) {
793
844
  await crudPage.runCreateWorkflow(data);`;
794
845
  break;
795
846
  case 'read':
796
- body = ` await openModule(page);
847
+ // create 用例(只读模块,如纯统计报表)时 read 无 workflowData 可依赖,
848
+ // 退化为独立查询:操作查询控件并触发搜索,不断言特定数据命中;
849
+ // 骨架返回执行结论(passed / needs_review 需人工复核),由 body 回传给报告层。
850
+ body = dataKey === ''
851
+ ? ` await openModule(page);
852
+ const crudPage = new CrudPage(page, crudSlots);
853
+ return crudPage.runStandaloneReadWorkflowByField(${singleQuoted(key)});`
854
+ : ` await openModule(page);
797
855
  const data = workflowDataByKey[${singleQuoted(dataKey)}];
798
856
  if (!data) {
799
857
  throw new Error(${singleQuoted(`${testCase.title} 依赖 create(${dataKey})用例先行生成 workflowData`)});
800
858
  }
801
859
  const crudPage = new CrudPage(page, crudSlots);
802
- await crudPage.runReadWorkflowByField(data, ${singleQuoted(key)});`;
860
+ return crudPage.runReadWorkflowByField(data, ${singleQuoted(key)});`;
803
861
  break;
804
862
  case 'update':
805
863
  body = ` await openModule(page);
@@ -890,8 +948,8 @@ function renderExportSlotsFromSlot(exportSlot, listIframeSrcKeyword) {
890
948
  listIframeSrcKeyword: ${singleQuoted(listIframeSrcKeyword)},
891
949
  },
892
950
  locators: {
893
- exportButton: '.mini-button.mini-dataexport',
894
- },
951
+ exportButton: ${singleQuoted(exportSlot.locators?.exportButton ?? '.mini-button.mini-dataexport')},
952
+ ${renderOptionalStringProperty('exportContainer', exportSlot.locators?.exportContainer, 12)} },
895
953
  export: {
896
954
  label: ${singleQuoted(exportSlot.label)},
897
955
  mode: ${exportSlot.mode === 'all' ? "'all' as const" : singleQuoted(exportSlot.mode)},
@@ -943,9 +1001,13 @@ export function generateStageSkeletonCrudSpecFromSlots(options) {
943
1001
  const exportCaseRef = (glueCases ?? []).find((testCase) => testCase.title.endsWith(' export'));
944
1002
  return `import { test, expect, type Page } from '@playwright/test';
945
1003
  import { loadCredentials, StagePath, type Credential } from '@epoint-testtech/stage-core';
946
- import { CrudPage, ExportPage, LoginPage${childGridImport}, type CrudFlowSlots, type ExportFlowSlots, type CrudWorkflowData } from '../../skeletons';
1004
+ import { CrudPage, ExportPage, LoginPage${childGridImport}, type CrudFlowSlots, type ExportFlowSlots, type CrudWorkflowData, type LoginSlots } from '../../skeletons';
947
1005
  import { GlueWorkflowRecorder, runRecordedWorkflow, type GlueWorkflowRecordInput } from '../../report/glue-report';
948
1006
 
1007
+ // loginSlots 为 credentials.json5 的可选扩展字段:stage-core 的 loadCredentials 运行时整体透传,
1008
+ // 类型由 login 骨架本地定义(不依赖 stage-core 导出),此处做交叉类型补齐。
1009
+ type GlueCredential = Credential & { loginSlots?: LoginSlots };
1010
+
949
1011
  test.describe(${singleQuoted(suiteTitle)}, () => {
950
1012
  const directListUrl = ${singleQuoted(directUrl)};
951
1013
  const credentialsPath = \`\${StagePath.projectRootDirPath}/src/tests/${requirementName}/credentials.json5\`;
@@ -973,7 +1035,7 @@ ${childGridSlots?.length ? ` const childGridSlots: ChildGridFlowSlots[] = ${ren
973
1035
  recorder.writeReport();
974
1036
  });
975
1037
 
976
- function resolveCredential(role: string): Credential {
1038
+ function resolveCredential(role: string): GlueCredential {
977
1039
  const [credential] = loadCredentials(credentialsPath, role);
978
1040
  if (!credential) {
979
1041
  throw new Error(\`credentials.json5 中未找到角色: \${role}\`);
@@ -981,15 +1043,16 @@ ${childGridSlots?.length ? ` const childGridSlots: ChildGridFlowSlots[] = ${ren
981
1043
  return credential;
982
1044
  }
983
1045
 
984
- function applyLoginCredential(credential: Credential): void {
1046
+ function applyLoginCredential(credential: GlueCredential): void {
985
1047
  process.env.LOGIN_SYSTEM_URL = credential.url;
986
1048
  process.env.LOGIN_USERNAME = credential.username;
987
1049
  process.env.LOGIN_PASSWORD = credential.password;
988
1050
  }
989
1051
 
990
1052
  async function openModule(page: Page): Promise<void> {
991
- applyLoginCredential(resolveCredential(primaryRole));
992
- const loginPage = new LoginPage(page);
1053
+ const credential = resolveCredential(primaryRole);
1054
+ applyLoginCredential(credential);
1055
+ const loginPage = new LoginPage(page, credential.loginSlots);
993
1056
  await loginPage.login();
994
1057
  await loginPage.gotoDirectUrl(directListUrl);
995
1058
  }
@@ -22,6 +22,12 @@ export type AiRuntimeResult = {
22
22
  source: string;
23
23
  note: string;
24
24
  }>;
25
+ /**
26
+ * 人工复审原因(needs_review 专用):Agent O-D-A-V 的差异化证据结论
27
+ * (字段是否在列表列、默认值、尝试值及来源、搜索前后数量)。
28
+ * CLI 生成的占位 result 无此字段,报告层回退 observation.summary。
29
+ */
30
+ reviewReason?: string;
25
31
  };
26
32
  export type RepairRound = {
27
33
  round: number;
@@ -1 +1 @@
1
- {"version":3,"file":"ai-runtime-trace.d.ts","sourceRoot":"","sources":["../../../src/runtime/ai-runtime-trace.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,cAAc,GAAG,QAAQ,CAAC;AAEjG,MAAM,MAAM,aAAa,GAAG;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,MAAM,EAAE,mBAAmB,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,mBAAmB,CAAC;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACpD,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,QAAQ,GAAG,UAAU,GAAG,SAAS,GAAG,cAAc,CAAC;IAC3D,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,aAAa,EAAE,8BAA8B,CAAC;IAC9C,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,YAAY,EAAE,WAAW,EAAE,CAAC;CAC7B,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE;IACjD,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,aAAa,EAAE,CAAC;CACxB,GAAG,cAAc,CASjB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,GAAG,IAAI,CAGlF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG;IAClD,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,mBAAmB,EAAE,OAAO,CAAC;CAC9B,CAmCA;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAQvD;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG,MAAM,CAyBT"}
1
+ {"version":3,"file":"ai-runtime-trace.d.ts","sourceRoot":"","sources":["../../../src/runtime/ai-runtime-trace.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,cAAc,GAAG,QAAQ,CAAC;AAEjG,MAAM,MAAM,aAAa,GAAG;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,MAAM,EAAE,mBAAmB,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,mBAAmB,CAAC;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnD;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,QAAQ,GAAG,UAAU,GAAG,SAAS,GAAG,cAAc,CAAC;IAC3D,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,aAAa,EAAE,8BAA8B,CAAC;IAC9C,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,YAAY,EAAE,WAAW,EAAE,CAAC;CAC7B,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE;IACjD,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,aAAa,EAAE,CAAC;CACxB,GAAG,cAAc,CASjB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,GAAG,IAAI,CAGlF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG;IAClD,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,mBAAmB,EAAE,OAAO,CAAC;CAC9B,CAmCA;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAQvD;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG,MAAM,CAyBT"}
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import type { ExtractedCodeListJson5 } from '../extractors/code-list-json5.js';
19
19
  import type { EpStageContractV1Draft } from '../contracts/glue-contract-v1.js';
20
+ import { type QueryComponentKind } from './query-component-map.js';
20
21
  import type { EpStageTestcaseV1Draft } from './testcase-v1-draft.js';
21
22
  /** assembler 输入。 */
22
23
  export type AssembleInput = {
@@ -42,23 +43,41 @@ type GlueActionKind = 'create' | 'read' | 'update' | 'delete' | 'export';
42
43
  export type CrudCaseStage = {
43
44
  actionKind: GlueActionKind;
44
45
  queryKeyLabel: string;
45
- component: 'input' | 'listbox';
46
+ component: QueryComponentKind;
46
47
  };
47
48
  /**
48
49
  * 按查询字段推导 CRUD 用例阶段序列。
49
50
  *
50
- * 规则:在新增表单中出现的查询字段(可写)产出完整 create/read/update/delete 四条;
51
+ * 规则:input/listbox 且在新增表单中出现的查询字段(可写)产出完整 create/read/update/delete 四条;
52
+ * datepicker/tree 不参与 create/update/delete(createSelections 机制只认 listbox),一律只产 read 一条;
51
53
  * 不在表单中的字段(如系统管理的状态)只产出 read 一条。
52
54
  * export 不在此计划内,由装配器单独追加。
53
55
  *
54
- * @param searchFields - 列表页查询字段(有序)。
56
+ * @param searchFields - 列表页查询字段(有序,仅含白名单组件)。
55
57
  * @param formFieldLabels - 新增表单字段标签集合。
56
58
  * @returns 有序阶段计划,顺序与 buildGlueCases 产出的用例顺序一一对应。
57
59
  */
58
60
  export declare function planCrudCaseStages(searchFields: Array<{
59
61
  label: string;
60
- component: 'input' | 'listbox';
62
+ component: QueryComponentKind;
61
63
  }>, formFieldLabels: string[]): CrudCaseStage[];
64
+ /**
65
+ * 生成 glue 用例执行字段。
66
+ *
67
+ * @param actionKind - CRUD / Export 动作类型。
68
+ * @param queryKey - 当前用例查询主键。
69
+ * @param searchFieldLabels - 列表页查询字段标签集合。
70
+ * @param dataKeyLabel - dataKey 对应的中文标签。
71
+ * @param deleteOutcome - 删除策略(与 skeletonSlots.assertions.deletePolicy 同源),
72
+ * 仅 delete 用例消费;缺省按 success_delete 出文案。
73
+ * @returns testcase 执行字段。
74
+ */
75
+ export declare function buildGlueExecutionFields(actionKind: GlueActionKind, queryKeyLabel: string, queryComponent: QueryComponentKind, queryKeyInForm: boolean, searchFieldLabels: string[], dataKeyLabel?: string, deleteOutcome?: {
76
+ deletePolicy: 'success_delete' | 'blocked_by_business_rule';
77
+ blockedDeleteMessage?: string;
78
+ /** 条件性删除(如"草稿可删、在用不可删"):删除断言写双预期,运行时按实际结果分流 */
79
+ conditional?: boolean;
80
+ }): Pick<EpStageTestcaseV1Draft['cases'][number], 'queryKey' | 'precondition' | 'steps' | 'assertion'>;
62
81
  /**
63
82
  * 装配 contract + testcase + inference-trace 账本。
64
83
  *
@@ -1 +1 @@
1
- {"version":3,"file":"contract-testcase-assembler.d.ts","sourceRoot":"","sources":["../../../src/testcase/contract-testcase-assembler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,sBAAsB,EAA2E,MAAM,kCAAkC,CAAC;AACxJ,OAAO,KAAK,EAAE,sBAAsB,EAAqB,MAAM,kCAAkC,CAAC;AAElG,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AASrE,oBAAoB;AACpB,MAAM,MAAM,aAAa,GAAG;IAC1B,iCAAiC;IACjC,QAAQ,EAAE,sBAAsB,CAAC;IACjC,qCAAqC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wCAAwC;IACxC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,gEAAgE;AAChE,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,EAAE,sBAAsB,CAAC;IACjC,QAAQ,EAAE,sBAAsB,CAAC;IACjC,mEAAmE;IACnE,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACzC,CAAC;AAkZF,mBAAmB;AACnB,KAAK,cAAc,GAAG,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEzE;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,EAAE,cAAc,CAAC;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,OAAO,GAAG,SAAS,CAAC;CAChC,CAAC;AAEF;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,GAAG,SAAS,CAAA;CAAE,CAAC,EACtE,eAAe,EAAE,MAAM,EAAE,GACxB,aAAa,EAAE,CAejB;AAsbD;;;;;GAKG;AACH,wBAAgB,gCAAgC,CAAC,KAAK,EAAE,aAAa,GAAG,cAAc,CAyLrF"}
1
+ {"version":3,"file":"contract-testcase-assembler.d.ts","sourceRoot":"","sources":["../../../src/testcase/contract-testcase-assembler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,sBAAsB,EAA2E,MAAM,kCAAkC,CAAC;AACxJ,OAAO,KAAK,EAAE,sBAAsB,EAA2C,MAAM,kCAAkC,CAAC;AACxH,OAAO,EAAyB,KAAK,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAE1F,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AASrE,oBAAoB;AACpB,MAAM,MAAM,aAAa,GAAG;IAC1B,iCAAiC;IACjC,QAAQ,EAAE,sBAAsB,CAAC;IACjC,qCAAqC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wCAAwC;IACxC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,gEAAgE;AAChE,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,EAAE,sBAAsB,CAAC;IACjC,QAAQ,EAAE,sBAAsB,CAAC;IACjC,mEAAmE;IACnE,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACzC,CAAC;AA2cF,mBAAmB;AACnB,KAAK,cAAc,GAAG,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEzE;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,EAAE,cAAc,CAAC;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,kBAAkB,CAAC;CAC/B,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,kBAAkB,CAAA;CAAE,CAAC,EACrE,eAAe,EAAE,MAAM,EAAE,GACxB,aAAa,EAAE,CAgBjB;AA2CD;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,cAAc,EAC1B,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,kBAAkB,EAClC,cAAc,EAAE,OAAO,EACvB,iBAAiB,EAAE,MAAM,EAAE,EAC3B,YAAY,CAAC,EAAE,MAAM,EACrB,aAAa,CAAC,EAAE;IACd,YAAY,EAAE,gBAAgB,GAAG,0BAA0B,CAAC;IAC5D,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,+CAA+C;IAC/C,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,GACA,IAAI,CAAC,sBAAsB,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,cAAc,GAAG,OAAO,GAAG,WAAW,CAAC,CA8GpG;AAiTD;;;;;GAKG;AACH,wBAAgB,gCAAgC,CAAC,KAAK,EAAE,aAAa,GAAG,cAAc,CA6QrF"}
@@ -15,6 +15,7 @@
15
15
  * - locators:用 014 草案已确认的框架默认(MiniUI 默认规则 + text=确定 等),不进 unresolvedSlots
16
16
  * - assertions.deletePolicy='success_delete'(MiniUI 默认删除成功策略)
17
17
  */
18
+ import { resolveQueryComponent } from './query-component-map.js';
18
19
  import { extractHtmlPage } from '../extractors/html-page.js';
19
20
  import { inferButtonInteractions, } from './button-interaction-inference.js';
20
21
  import { existsSync } from 'node:fs';
@@ -72,6 +73,33 @@ function buildCrudLocators(listPage) {
72
73
  deleteConfirmButton: 'text=确定',
73
74
  };
74
75
  }
76
+ /**
77
+ * 从 list 页事实推导导出按钮定位器。
78
+ *
79
+ * 推导优先级(无来源不入契约的落地):
80
+ * 1. toolbarActions 中命中导出组件(backendMethod=getExportModel / component=mini-dataexport / label=导出)
81
+ * 且 elementHints 带 id → `xpath=//*[@id='<id>']`(页面级唯一锚点,最稳);
82
+ * 2. 命中导出组件但无 id 线索 → `a.mini-dataexport`(MiniUI 导出组件实际渲染类名,
83
+ * 历史默认 '.mini-button.mini-dataexport' 多假设了 mini-button 类,实测页面并无该类);
84
+ * 3. 页面无导出事实 → 回退历史框架默认 '.mini-button.mini-dataexport'。
85
+ */
86
+ function buildExportButtonLocator(listPage) {
87
+ const exportToolbar = (listPage.toolbarActions ?? []).find((t) => {
88
+ return t.backendMethod === 'getExportModel' || t.component === 'mini-dataexport' || t.label === '导出';
89
+ });
90
+ const idHint = exportToolbar?.elementHints?.find((h) => h.attr === 'id' && h.value)?.value;
91
+ if (idHint) {
92
+ return `xpath=//*[@id='${idHint}']`;
93
+ }
94
+ if (exportToolbar?.component === 'mini-dataexport') {
95
+ return 'a.mini-dataexport';
96
+ }
97
+ const exportButton = (listPage.buttons ?? []).find((b) => b.action === 'export');
98
+ if (exportButton) {
99
+ return `xpath=//a[contains(@class,'mini-dataexport')][normalize-space()='${exportButton.label || '导出'}']`;
100
+ }
101
+ return '.mini-button.mini-dataexport';
102
+ }
75
103
  /**
76
104
  * 从 code_list.pages[role=list/add/edit].pageId 提取 iframe src keyword。
77
105
  *
@@ -275,19 +303,29 @@ function buildChildGridSlots(codeList, sourceRootOverride) {
275
303
  return slots.length > 0 ? slots : undefined;
276
304
  }
277
305
  /**
278
- * 把 list 页 queryFields 映射成 skeletonSlots.crud.searchConditions(input/listbox 二分)。
306
+ * 把 list 页 queryFields 映射成 skeletonSlots.crud.searchConditions
307
+ *
308
+ * 白名单语义(query-component-map.ts 单一事实来源):
309
+ * - input/listbox/datepicker/tree → 生成对应槽位;
310
+ * - 未识别组件(显式写了但不在映射表)→ 返回 null 不进槽位,用例由 AI 桶承载;
311
+ * - 上游未写 component → 回退 input(保住 CRUD 锚点主链路),由调用方记 inference-trace。
279
312
  *
280
313
  * @param codeList - 解析后的 code_list。
281
314
  * @param listPage - 列表页 page item。
282
- * @returns 对齐 StageSkeletonCrudSearchConditionSlot 的槽位数组。
315
+ * @returns 对齐 StageSkeletonCrudSearchConditionSlot 的槽位数组(null 已过滤)。
283
316
  */
284
317
  function buildSearchConditionSlots(codeList, listPage, addOrEditPage) {
285
318
  const addFormLabels = new Set((addOrEditPage?.formFields ?? [])
286
319
  .filter((f) => typeof f !== 'string')
287
320
  .map((f) => f.label));
288
- return (listPage.queryFields ?? []).map((fieldInfo) => {
321
+ return (listPage.queryFields ?? [])
322
+ .map((fieldInfo) => {
289
323
  const field = mapQueryFieldToField(fieldInfo);
290
- const component = normalizeQueryComponent(fieldInfo);
324
+ const resolution = resolveQueryComponent(fieldInfo);
325
+ if (resolution.source === 'unrecognized') {
326
+ return null;
327
+ }
328
+ const component = resolution.kind;
291
329
  if (component === 'input') {
292
330
  // MiniUI mini-textbox 的 name 属性通常是 `dataBean.<field>` 或省略;只有 label 属性稳定。
293
331
  // 用 label 定位 outer div (mini-textbox 容器),再取内部 input.mini-textbox-input。
@@ -303,6 +341,33 @@ function buildSearchConditionSlots(codeList, listPage, addOrEditPage) {
303
341
  listLocator: miniTextboxByLabel,
304
342
  };
305
343
  }
344
+ if (component === 'datepicker') {
345
+ // mini-daterangepicker/mini-datepicker:单输入框形态。优先用 elementHints 的 id 锚点,
346
+ // 缺省按 label 容器派生;运行态若发现默认值无法形成有效过滤证据,再由 demote/ODAV 接管原用例。
347
+ const idHint = (fieldInfo.elementHints ?? []).find((h) => h.attr === 'id' && h.value)?.value;
348
+ return {
349
+ component: 'datepicker',
350
+ field,
351
+ label: fieldInfo.label,
352
+ listLocator: idHint ? `xpath=//*[@id='${idHint}']` : `xpath=//div[@label='${fieldInfo.label}']//input`,
353
+ searchAssertion: 'none',
354
+ triggerSearch: true,
355
+ };
356
+ }
357
+ if (component === 'tree') {
358
+ // mini-filtertree:左树右表,默认按页面既有 nodeselect 联动执行;若真实页面需要显式搜索,
359
+ // 由运行失败后的 demote/ODAV 保底接管原用例。
360
+ const idHint = (fieldInfo.elementHints ?? []).find((h) => h.attr === 'id' && h.value)?.value;
361
+ return {
362
+ component: 'tree',
363
+ field,
364
+ label: fieldInfo.label,
365
+ ...(idHint ? { treeLocator: `#${idHint}` } : {}),
366
+ nodeStrategy: 'first_leaf',
367
+ searchOnSelect: true,
368
+ searchAssertion: 'none',
369
+ };
370
+ }
306
371
  // listbox:真实下拉值来自 dataOptions 字典 (运行时才能确认),assembler 不猜值;
307
372
  // 用 skeleton 支持的 valueStrategy=first_non_placeholder + skipValues=['请选择'] 让运行时
308
373
  // 选首个有效项。searchAssertion='none' 表示 Read 工作流不对 listbox 校验命中数(因为选到
@@ -329,7 +394,8 @@ function buildSearchConditionSlots(codeList, listPage, addOrEditPage) {
329
394
  ]
330
395
  : [],
331
396
  };
332
- });
397
+ })
398
+ .filter((slot) => slot !== null);
333
399
  }
334
400
  /**
335
401
  * 把 code_list 中文 label 映射到契约 field(kebab-ish 英文)。
@@ -350,30 +416,23 @@ function mapQueryFieldToField(fieldInfo) {
350
416
  const bindMatch = fieldInfo.bind?.match(/dataBean\.([A-Za-z0-9_]+)/);
351
417
  return bindMatch?.[1] ?? mapLabelToField(fieldInfo.label);
352
418
  }
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
419
  /**
363
420
  * 按查询字段推导 CRUD 用例阶段序列。
364
421
  *
365
- * 规则:在新增表单中出现的查询字段(可写)产出完整 create/read/update/delete 四条;
422
+ * 规则:input/listbox 且在新增表单中出现的查询字段(可写)产出完整 create/read/update/delete 四条;
423
+ * datepicker/tree 不参与 create/update/delete(createSelections 机制只认 listbox),一律只产 read 一条;
366
424
  * 不在表单中的字段(如系统管理的状态)只产出 read 一条。
367
425
  * export 不在此计划内,由装配器单独追加。
368
426
  *
369
- * @param searchFields - 列表页查询字段(有序)。
427
+ * @param searchFields - 列表页查询字段(有序,仅含白名单组件)。
370
428
  * @param formFieldLabels - 新增表单字段标签集合。
371
429
  * @returns 有序阶段计划,顺序与 buildGlueCases 产出的用例顺序一一对应。
372
430
  */
373
431
  export function planCrudCaseStages(searchFields, formFieldLabels) {
374
432
  const stages = [];
375
433
  for (const field of searchFields) {
376
- if (formFieldLabels.includes(field.label)) {
434
+ const canWrite = field.component === 'input' || field.component === 'listbox';
435
+ if (canWrite && formFieldLabels.includes(field.label)) {
377
436
  stages.push({ actionKind: 'create', queryKeyLabel: field.label, component: field.component }, { actionKind: 'read', queryKeyLabel: field.label, component: field.component }, { actionKind: 'update', queryKeyLabel: field.label, component: field.component }, { actionKind: 'delete', queryKeyLabel: field.label, component: field.component });
378
437
  }
379
438
  else {
@@ -425,7 +484,7 @@ function pickQueryKeyLabel(label, searchFieldLabels, dataKeyLabel) {
425
484
  * 仅 delete 用例消费;缺省按 success_delete 出文案。
426
485
  * @returns testcase 执行字段。
427
486
  */
428
- function buildGlueExecutionFields(actionKind, queryKeyLabel, queryComponent, queryKeyInForm, searchFieldLabels, dataKeyLabel, deleteOutcome) {
487
+ export function buildGlueExecutionFields(actionKind, queryKeyLabel, queryComponent, queryKeyInForm, searchFieldLabels, dataKeyLabel, deleteOutcome) {
429
488
  const queryKey = actionKind === 'export'
430
489
  ? pickQueryKeyLabel(dataKeyLabel ?? searchFieldLabels[0] ?? '不适用', searchFieldLabels, dataKeyLabel)
431
490
  : pickQueryKeyLabel(queryKeyLabel, searchFieldLabels, dataKeyLabel);
@@ -458,7 +517,7 @@ function buildGlueExecutionFields(actionKind, queryKeyLabel, queryComponent, que
458
517
  `1. 在列表页${queryKey}查询条件中输入或选择一个有效查询值。`,
459
518
  '2. 点击{{查询按钮}}。',
460
519
  ].join(' '),
461
- assertion: '查询动作应完成,列表刷新或查询请求被触发;本用例不强制断言列表数据命中数量。',
520
+ assertion: '查询请求应真实发出且成功;搜索前后列表总数应可读取并对比——数据量无变化或查询字段不在列表列时,本用例标记为需人工复核。',
462
521
  };
463
522
  }
464
523
  return {
@@ -659,6 +718,42 @@ function buildUncoveredCandidates(gapCandidates, firstRole, sequence, codeList,
659
718
  };
660
719
  });
661
720
  }
721
+ /**
722
+ * 为未识别组件的查询字段构建 AI 候选用例。
723
+ *
724
+ * 与 buildUncoveredCandidates(custom 按钮路径)分离:查询字段无按钮元数据可反查,
725
+ * 直接按字段事实生成「操作查询控件并触发查询」的 AI 任务。
726
+ *
727
+ * @param queryFieldResolutions - 查询字段与其组件解析结果。
728
+ * @param firstRole - 首个角色(用作 requiredRole)。
729
+ * @param sequence - 用例编号上下文。
730
+ */
731
+ function buildQueryComponentUncoveredCandidates(queryFieldResolutions, firstRole, sequence) {
732
+ return queryFieldResolutions
733
+ .filter((r) => r.resolution.source === 'unrecognized')
734
+ .map(({ fieldInfo, resolution }) => {
735
+ const raw = resolution.source === 'unrecognized' ? resolution.raw : '';
736
+ const steps = [
737
+ `1. 在列表页定位「${fieldInfo.label}」查询控件(原始组件 ${raw},骨架 glue 白名单未覆盖)。`,
738
+ '2. 观察控件真实形态并完成一次有效查询值设置(如下拉选项、日期范围、树节点等)。',
739
+ '3. 触发查询(点击查询按钮或控件自带联动)。',
740
+ ].join(' ');
741
+ const assertion = `「${fieldInfo.label}」查询链路应可驱动:查询请求被触发,列表刷新或返回结果。`;
742
+ return {
743
+ caseId: nextCaseId(sequence),
744
+ label: `${fieldInfo.label}查询(组件 ${raw})`,
745
+ generationStrategy: 'ai',
746
+ coverageStatus: 'gap',
747
+ requiredRole: firstRole,
748
+ businessIntent: `覆盖查询字段「${fieldInfo.label}」(原始组件 ${raw} 未在 glue 白名单,需运行时观察交互)。`,
749
+ assertionExpectation: assertion,
750
+ queryKey: fieldInfo.label,
751
+ precondition: `1. 使用角色:${firstRole}。 2. 完成系统登录并进入目标待测页面。`,
752
+ steps,
753
+ assertion,
754
+ };
755
+ });
756
+ }
662
757
  /**
663
758
  * 按 code_list 里的系统与角色事实生成 testcase.testAccess 占位。
664
759
  *
@@ -714,10 +809,27 @@ export function assembleJson5ContractAndTestcase(input) {
714
809
  codeList.roles[0]?.role ??
715
810
  '业务角色待确认';
716
811
  const directUrl = resolveDirectUrl(input, listPage);
717
- const searchFields = (listPage.queryFields ?? []).map((fieldInfo) => ({
812
+ // 组件形态解析:白名单(input/listbox/datepicker/tree)写规范 kind 进契约;
813
+ // input/listbox 进入 glue,datepicker/tree 的查询语义由 ODAV 保底;
814
+ // 上游显式写了但未识别的组件保留原始字符串,用例路由 AI 桶,不再静默压扁成 listbox。
815
+ const queryFieldResolutions = (listPage.queryFields ?? []).map((fieldInfo) => ({
816
+ fieldInfo,
817
+ resolution: resolveQueryComponent(fieldInfo),
818
+ }));
819
+ const searchFields = queryFieldResolutions.map(({ fieldInfo, resolution }) => ({
718
820
  field: mapQueryFieldToField(fieldInfo),
719
821
  label: fieldInfo.label,
720
- component: normalizeQueryComponent(fieldInfo),
822
+ component: resolution.source === 'unrecognized'
823
+ ? resolution.raw
824
+ : resolution.kind,
825
+ }));
826
+ // glue 通道承载所有已识别的通用查询字段;若运行态证明某个字段的骨架动作不足,
827
+ // 再通过 demote/ODAV 接管原 glue 用例,不在装配期拆成第二条用例。
828
+ const glueSearchFields = queryFieldResolutions
829
+ .filter((r) => r.resolution.source !== 'unrecognized')
830
+ .map(({ fieldInfo, resolution }) => ({
831
+ label: fieldInfo.label,
832
+ component: resolution.kind,
721
833
  }));
722
834
  const searchFieldLabels = searchFields.map((fieldInfo) => fieldInfo.label);
723
835
  const addOrEditPage = codeList.pages.find((p) => p.role === 'add') ?? codeList.pages.find((p) => p.role === 'edit');
@@ -766,10 +878,51 @@ export function assembleJson5ContractAndTestcase(input) {
766
878
  // 条件性删除标记:骨架 runDeleteWorkflow 据此在运行时按「记录是否仍存在」分流断言
767
879
  // (存在 → 断言阻断文案;不存在 → 断言删除成功),而非 slots 级单一预期。
768
880
  ...(conditionalDelete ? { conditionalDelete: true } : {}),
881
+ // 查询请求级断言关键字:FUI 框架的查询请求走 `/rest/<模块路径>/<restController>/page_Refresh`
882
+ // 通用端点(方法名在加密 body 里,不在 URL——001 运行时实据),因此用 initAction
883
+ // (restController 名,出现在 URL 路径)作关键字;initAction 缺失时回退 dataGrid.backendMethod。
884
+ ...((listPage.initAction ?? listPage.dataGrid?.backendMethod)
885
+ ? { searchResponseUrlKeyword: listPage.initAction ?? listPage.dataGrid?.backendMethod }
886
+ : {}),
769
887
  },
770
888
  };
889
+ // 列表页列头标签:搜索校验的「字段列可见性」判定依据(查询字段不在列中时骨架标记需人工复核)
890
+ const gridColumns = (listPage.dataGrid?.columns ?? [])
891
+ .map((column) => column.label)
892
+ .filter((label) => Boolean(label));
893
+ if (gridColumns.length > 0) {
894
+ skeletonSlotsCrud.gridColumns = gridColumns;
895
+ }
771
896
  const childGridSlots = buildChildGridSlots(codeList, input.sourceRoot);
772
- // 动态推导 gapCandidates:当前阶段只保留控件类 custom 按钮。
897
+ // unresolvedSlots:装配期无法确认的槽位显式登记(契约可观测性 gate)。
898
+ const unresolvedSlots = [];
899
+ // 1) 未识别查询组件 → AI 通道路由登记
900
+ for (const { fieldInfo, resolution } of queryFieldResolutions) {
901
+ if (resolution.source !== 'unrecognized')
902
+ continue;
903
+ unresolvedSlots.push({
904
+ slotPath: `scenario.searchFields[${fieldInfo.label}]`,
905
+ kind: 'unsupported_query_component',
906
+ rawComponent: resolution.raw,
907
+ reason: `查询字段「${fieldInfo.label}」的组件 ${resolution.raw} 不在 glue 白名单映射表(query-component-map.ts),用例改走 AI 通道。`,
908
+ suggestedAction: 'ai_runtime',
909
+ detectedAt: 'assemble',
910
+ evidence: { source: 'code_list', ref: `pages[role=list].queryFields[${fieldInfo.label}]` },
911
+ });
912
+ }
913
+ // 2) 多列表页显式化:当前管线只装配第一个 list 页,其余登记待人工拆分
914
+ const listPages = codeList.pages.filter((p) => p.role === 'list');
915
+ if (listPages.length > 1) {
916
+ unresolvedSlots.push({
917
+ slotPath: 'scenario.skeletonSlots.crud',
918
+ kind: 'multi_list_page',
919
+ reason: `code_list 含 ${listPages.length} 个 role=list 页面,当前管线仅装配第一个(${listPage.pageId ?? listPage.path}),跳过:${listPages.slice(1).map((p) => p.pageId ?? p.path).join('、')}。`,
920
+ suggestedAction: 'manual_split',
921
+ detectedAt: 'assemble',
922
+ evidence: { source: 'code_list', ref: 'pages[role=list]' },
923
+ });
924
+ }
925
+ // 动态推导 gapCandidates:当前阶段保留控件类 custom 按钮 + 未识别组件查询字段。
773
926
  // 状态流转、子表、详情、统计看板/移动端等 PRD-only/业务类场景暂不进 gapCandidates,
774
927
  // 后续可通过 code_list.gapHints 让业务人工声明后再扩展。
775
928
  const gapCandidates = [];
@@ -783,6 +936,16 @@ export function assembleJson5ContractAndTestcase(input) {
783
936
  generationStrategy: 'ai',
784
937
  });
785
938
  });
939
+ // 未识别组件查询字段进 AI 桶(gapId 规范:query-component:<field>)
940
+ for (const { fieldInfo, resolution } of queryFieldResolutions) {
941
+ if (resolution.source !== 'unrecognized')
942
+ continue;
943
+ gapCandidates.push({
944
+ gapId: `query-component:${mapQueryFieldToField(fieldInfo)}`,
945
+ label: fieldInfo.label,
946
+ generationStrategy: 'ai',
947
+ });
948
+ }
786
949
  const contract = {
787
950
  schemaVersion: 'ep-stage-contract/v1-draft',
788
951
  contractId: `${module}-contract`,
@@ -800,15 +963,20 @@ export function assembleJson5ContractAndTestcase(input) {
800
963
  searchFields,
801
964
  skeletonSlots: {
802
965
  crud: skeletonSlotsCrud,
803
- export: { moduleId: module, label: '导出', mode: 'all' },
966
+ export: {
967
+ moduleId: module,
968
+ label: '导出',
969
+ mode: 'all',
970
+ locators: { exportButton: buildExportButtonLocator(listPage) },
971
+ },
804
972
  childGrids: childGridSlots,
805
973
  },
806
974
  gapCandidates,
807
975
  },
808
- unresolvedSlots: [], // 框架默认策略都已确认,不进 unresolvedSlots
976
+ unresolvedSlots,
809
977
  };
810
978
  const caseIdSequence = { pageIndex: 1, nextCaseIndex: 1 };
811
- const cases = buildGlueCases(moduleName, searchFields, formFieldLabels, caseIdSequence, dataKeyLabel, {
979
+ const cases = buildGlueCases(moduleName, glueSearchFields, formFieldLabels, caseIdSequence, dataKeyLabel, {
812
980
  deletePolicy,
813
981
  blockedDeleteMessage,
814
982
  conditional: conditionalDelete,
@@ -819,6 +987,8 @@ export function assembleJson5ContractAndTestcase(input) {
819
987
  sourceRootOverride: input.sourceRoot,
820
988
  });
821
989
  const uncoveredCandidates = buildUncoveredCandidates(gapCandidates, firstRole, caseIdSequence, codeList, buttonInferences);
990
+ // 未识别组件查询字段的 AI 候选(按钮反查路径不适用,独立构建)
991
+ uncoveredCandidates.push(...buildQueryComponentUncoveredCandidates(queryFieldResolutions, firstRole, caseIdSequence));
822
992
  const testcase = {
823
993
  schemaVersion: 'ep-stage-testcase/v1-draft',
824
994
  testcaseId: `${module}-testcase`,
@@ -843,20 +1013,27 @@ export function assembleJson5ContractAndTestcase(input) {
843
1013
  source: 'framework-default',
844
1014
  reason: '014 草案已确认 MiniUI 默认规则 + text=确定 确认按钮 + addAutofillTrigger 默认能力',
845
1015
  },
1016
+ exportButtonLocator: {
1017
+ source: 'code-list-derived',
1018
+ reason: '优先取 list 页 toolbarActions 导出组件的 elementHints id;无 id 时按 mini-dataexport 实际渲染类名兜底',
1019
+ },
846
1020
  assertions: {
847
1021
  source: 'framework-default',
848
1022
  reason: "deletePolicy='success_delete'(MiniUI 默认删除成功策略)",
849
1023
  },
850
1024
  queryFields: {
851
1025
  source: 'code-list-derived',
852
- reason: 'placename dataKey 用 timestamp_prefix;listbox 取首项作 value',
1026
+ reason: '组件形态经 query-component-map.ts 显式映射:白名单(input/listbox/datepicker/tree)进契约槽位;' +
1027
+ '所有已识别组件进入 glue,用例先走通用骨架;运行态若语义超出骨架,再由同一 caseId 的 ODAV 接管;' +
1028
+ '未识别组件进 AI 桶并登记 unresolvedSlots;上游未写 component 回退 input(defaulted,待 calibrate 运行时纠正)。' +
1029
+ 'input 的 dataKey 字段用 timestamp_prefix;listbox 取首项作 value',
853
1030
  },
854
1031
  childGrids: {
855
1032
  source: 'html-derived',
856
1033
  reason: '从 add/edit 页 HTML 的 datagrid 列 editor.type 推导 component;locators 按 MiniUI 子表默认规则生成',
857
1034
  },
858
1035
  },
859
- unresolvedSlotsCount: 0,
1036
+ unresolvedSlotsCount: unresolvedSlots.length,
860
1037
  customButtonInferences: Array.from(buttonInferences.values()).map((inf) => ({
861
1038
  label: inf.label,
862
1039
  pattern: inf.pattern,