@hupan56/wlkj 2.7.11 → 3.0.0

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 (152) hide show
  1. package/bin/cli.js +375 -78
  2. package/package.json +29 -29
  3. package/templates/.qoder/.runtime/ctx-cache-5660152f1d6dd819.md +23 -0
  4. package/templates/.qoder/.runtime/ctx-cache-afdce0dac06b25b0.md +23 -0
  5. package/templates/.qoder/.runtime/search-cache-eae7644e7b122f35.txt +1 -0
  6. package/templates/.qoder/learning/eval-history.jsonl +28 -0
  7. package/templates/data/index/wiki-index.json +8 -0
  8. package/templates/qoder/agents/insight-planning.md +1 -1
  9. package/templates/qoder/agents/insight-research.md +28 -15
  10. package/templates/qoder/commands/optional/wl-insight.md +159 -161
  11. package/templates/qoder/commands/optional/wl-report.md +4 -4
  12. package/templates/qoder/commands/optional/wl-status.md +2 -2
  13. package/templates/qoder/commands/wl-code.md +2 -2
  14. package/templates/qoder/commands/wl-design.md +2 -2
  15. package/templates/qoder/commands/wl-init.md +3 -3
  16. package/templates/qoder/commands/wl-prd.md +2 -2
  17. package/templates/qoder/commands/wl-req.md +43 -0
  18. package/templates/qoder/commands/wl-search.md +8 -8
  19. package/templates/qoder/commands/wl-task.md +17 -17
  20. package/templates/qoder/commands/wl-test.md +41 -15
  21. package/templates/qoder/config.yaml +17 -1
  22. package/templates/qoder/hooks/session-start.py +12 -22
  23. package/templates/qoder/nul +4 -0
  24. package/templates/qoder/rules/wl-pipeline.md +22 -48
  25. package/templates/qoder/scripts/README.md +139 -0
  26. package/templates/qoder/scripts/common/autotest_auth.py +109 -0
  27. package/templates/qoder/scripts/common/bootstrap.py +145 -0
  28. package/templates/qoder/scripts/common/check_publish.py +98 -0
  29. package/templates/qoder/scripts/common/cmd_registry.py +112 -0
  30. package/templates/qoder/scripts/common/config.py +187 -0
  31. package/templates/qoder/scripts/common/contract.py +317 -0
  32. package/templates/qoder/scripts/common/developer.py +2 -1
  33. package/templates/qoder/scripts/common/feishu.py +10 -9
  34. package/templates/qoder/scripts/common/guard.py +159 -0
  35. package/templates/qoder/scripts/common/identity.py +121 -2
  36. package/templates/qoder/scripts/common/kg_capabilities.py +182 -0
  37. package/templates/qoder/scripts/common/mcp_base.py +268 -0
  38. package/templates/qoder/scripts/common/paths.py +187 -1
  39. package/templates/qoder/scripts/common/result.py +223 -0
  40. package/templates/qoder/scripts/common/roles.py +60 -0
  41. package/templates/qoder/scripts/common/task_utils.py +21 -9
  42. package/templates/qoder/scripts/common/test_extract.py +115 -0
  43. package/templates/qoder/scripts/kg/__init__.py +11 -0
  44. package/templates/qoder/scripts/kg/build_entity_registry.py +196 -0
  45. package/templates/qoder/scripts/kg/build_relations.py +127 -0
  46. package/templates/qoder/scripts/{build_style_index.py → kg/build_style_index.py} +39 -10
  47. package/templates/qoder/scripts/kg/build_workflows.py +144 -0
  48. package/templates/qoder/scripts/{context_pack.py → kg/context_pack.py} +18 -11
  49. package/templates/qoder/scripts/{enrich_prompt.py → kg/enrich_prompt.py} +232 -226
  50. package/templates/qoder/scripts/{extract_api_params.py → kg/extract.py} +398 -246
  51. package/templates/qoder/scripts/{kg.py → kg/kg.py} +638 -708
  52. package/templates/qoder/scripts/{kg_build.py → kg/kg_build.py} +618 -612
  53. package/templates/qoder/scripts/{kg_build_db.py → kg/kg_build_db.py} +333 -327
  54. package/templates/qoder/scripts/{kg_duckdb.py → kg/kg_duckdb.py} +38 -37
  55. package/templates/qoder/scripts/{kg_incremental.py → kg/kg_incremental.py} +420 -393
  56. package/templates/qoder/scripts/{kg_link_db.py → kg/kg_link_db.py} +230 -224
  57. package/templates/qoder/scripts/{kg_semantic.py → kg/kg_semantic.py} +156 -150
  58. package/templates/qoder/scripts/kg/prefetch.py +359 -0
  59. package/templates/qoder/scripts/{search_index.py → kg/search_index.py} +70 -14
  60. package/templates/qoder/scripts/mcp/__init__.py +11 -0
  61. package/templates/qoder/scripts/{kg_mcp_server.py → mcp/kg_mcp_server.py} +77 -272
  62. package/templates/qoder/scripts/{lanhu_stdio_wrapper.py → mcp/lanhu_stdio_wrapper.py} +125 -119
  63. package/templates/qoder/scripts/{check_mcp.py → mcp/mcp_doctor.py} +515 -298
  64. package/templates/qoder/scripts/{mcp_launcher.py → mcp/mcp_launcher.py} +442 -414
  65. package/templates/qoder/scripts/{mysql_mcp_server.py → mcp/mysql_mcp_server.py} +347 -396
  66. package/templates/qoder/scripts/{zentao_mcp_server.py → mcp/zentao_mcp_server.py} +384 -424
  67. package/templates/qoder/scripts/report/__init__.py +11 -0
  68. package/templates/qoder/scripts/{add_session.py → report/add_session.py} +250 -244
  69. package/templates/qoder/scripts/{archive_prd.py → report/archive_prd.py} +383 -377
  70. package/templates/qoder/scripts/{eval_prd.py → report/eval_prd.py} +73 -11
  71. package/templates/qoder/scripts/{export.py → report/export.py} +63 -0
  72. package/templates/qoder/scripts/{fill_prototype.py → report/fill_prototype.py} +6 -0
  73. package/templates/qoder/scripts/{gen_design_doc.py → report/gen_design_doc.py} +400 -394
  74. package/templates/qoder/scripts/{learn.py → report/learn.py} +152 -146
  75. package/templates/qoder/scripts/{learn_aggregate.py → report/learn_aggregate.py} +207 -201
  76. package/templates/qoder/scripts/{report.py → report/report.py} +287 -281
  77. package/templates/qoder/scripts/report/req.py +222 -0
  78. package/templates/qoder/scripts/report/role.py +33 -0
  79. package/templates/qoder/scripts/{status.py → report/status.py} +634 -628
  80. package/templates/qoder/scripts/setup/__init__.py +11 -0
  81. package/templates/qoder/scripts/setup/carriers.py +662 -0
  82. package/templates/qoder/scripts/{init_doctor.py → setup/init_doctor.py} +63 -26
  83. package/templates/qoder/scripts/{install_qoderwork.py → setup/install_qoderwork.py} +36 -26
  84. package/templates/qoder/scripts/{platform_doctor.py → setup/platform_doctor.py} +265 -259
  85. package/templates/qoder/scripts/{repo_root.py → setup/repo_root.py} +112 -106
  86. package/templates/qoder/scripts/{setup.py → setup/setup.py} +147 -0
  87. package/templates/qoder/scripts/{setup_lanhu.py → setup/setup_lanhu.py} +973 -963
  88. package/templates/qoder/scripts/task/__init__.py +11 -0
  89. package/templates/qoder/scripts/{git_sync.py → task/git_sync.py} +52 -31
  90. package/templates/qoder/scripts/{syncgate.py → task/syncgate.py} +6 -0
  91. package/templates/qoder/scripts/task/task.py +221 -0
  92. package/templates/qoder/scripts/task/task_lifecycle.py +596 -0
  93. package/templates/qoder/scripts/task/task_query.py +161 -0
  94. package/templates/qoder/scripts/task/task_relations.py +424 -0
  95. package/templates/qoder/scripts/{team_sync.py → task/team_sync.py} +93 -20
  96. package/templates/qoder/scripts/test/__init__.py +11 -0
  97. package/templates/qoder/scripts/{autotest.py → test/autotest.py} +1174 -1751
  98. package/templates/qoder/scripts/{autotest_batch.py → test/autotest_batch.py} +242 -224
  99. package/templates/qoder/scripts/test/autotest_data.py +675 -0
  100. package/templates/qoder/scripts/{autotest_run.py → test/autotest_run.py} +309 -297
  101. package/templates/qoder/scripts/{benchmark.py → test/benchmark.py} +6 -0
  102. package/templates/qoder/scripts/{kg_auto_login.py → test/kg_auto_login.py} +202 -196
  103. package/templates/qoder/scripts/{kg_test_runner.py → test/kg_test_runner.py} +7 -1
  104. package/templates/qoder/scripts/{page_probe.py → test/page_probe.py} +465 -459
  105. package/templates/qoder/scripts/wlkj.py +116 -0
  106. package/templates/qoder/settings.json +1 -10
  107. package/templates/qoder/skills/design-import/SKILL.md +226 -226
  108. package/templates/qoder/skills/design-review/SKILL.md +82 -82
  109. package/templates/qoder/skills/prd-generator/SKILL.md +26 -16
  110. package/templates/qoder/skills/prd-review/SKILL.md +5 -5
  111. package/templates/qoder/skills/prototype-generator/SKILL.md +256 -256
  112. package/templates/qoder/skills/spec-coder/SKILL.md +4 -4
  113. package/templates/qoder/skills/spec-generator/SKILL.md +4 -4
  114. package/templates/qoder/skills/test-generator/SKILL.md +5 -5
  115. package/templates/qoder/skills/wl-code/SKILL.md +4 -4
  116. package/templates/qoder/skills/wl-commit/SKILL.md +4 -4
  117. package/templates/qoder/skills/wl-design/SKILL.md +3 -3
  118. package/templates/qoder/skills/wl-init/SKILL.md +8 -8
  119. package/templates/qoder/skills/wl-insight/SKILL.md +5 -5
  120. package/templates/qoder/skills/wl-prd-full/SKILL.md +6 -6
  121. package/templates/qoder/skills/wl-prd-quick/SKILL.md +6 -6
  122. package/templates/qoder/skills/wl-prd-review/SKILL.md +4 -4
  123. package/templates/qoder/skills/wl-report/SKILL.md +7 -7
  124. package/templates/qoder/skills/wl-search/SKILL.md +13 -13
  125. package/templates/qoder/skills/wl-spec/SKILL.md +5 -5
  126. package/templates/qoder/skills/wl-status/SKILL.md +5 -5
  127. package/templates/qoder/skills/wl-task/SKILL.md +6 -6
  128. package/templates/qoder/skills/wl-test/SKILL.md +102 -39
  129. package/templates/root/AGENTS.md +39 -40
  130. package/templates/qoder/hooks/inject-workflow-state.py +0 -169
  131. package/templates/qoder/scripts/__pycache__/check_mcp_launch.cpython-39.pyc +0 -0
  132. package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
  133. package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
  134. package/templates/qoder/scripts/__pycache__/platform_doctor.cpython-39.pyc +0 -0
  135. package/templates/qoder/scripts/check_carriers.py +0 -238
  136. package/templates/qoder/scripts/check_mcp_launch.py +0 -183
  137. package/templates/qoder/scripts/check_qoderwork_consistency.py +0 -166
  138. package/templates/qoder/scripts/collect_prds.py +0 -31
  139. package/templates/qoder/scripts/common/mentions.py +0 -134
  140. package/templates/qoder/scripts/common/utf8.py +0 -38
  141. package/templates/qoder/scripts/extract_routes.py +0 -54
  142. package/templates/qoder/scripts/extract_routes_tree.py +0 -78
  143. package/templates/qoder/scripts/handoff.py +0 -22
  144. package/templates/qoder/scripts/init_developer.py +0 -76
  145. package/templates/qoder/scripts/parse_prds.py +0 -33
  146. package/templates/qoder/scripts/role.py +0 -51
  147. package/templates/qoder/scripts/sync_carriers.py +0 -259
  148. package/templates/qoder/scripts/task.py +0 -1261
  149. package/templates/qoder/scripts/workspace_init.py +0 -102
  150. package/templates/qoder/skills/prompt-enrich/SKILL.md +0 -90
  151. package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
  152. /package/templates/qoder/scripts/{secure-ls.js → test/secure-ls.js} +0 -0
@@ -1,459 +1,465 @@
1
- # -*- coding: utf-8 -*-
2
- """
3
- page_probe.py - 页面按钮+接口画像 (开发→测试交接物)
4
-
5
- DuckDB 知识图谱的 traces 表查"某个页面有哪些按钮、点按钮调哪个接口",
6
- 输出一份测试人员能照着写用例的画像。解决痛点:开发写完代码交接给测试时,
7
- 测试不用再满仓库找页面/按钮/接口。
8
-
9
- 数据源: data/index/kg.duckdb traces
10
- (vue_file, button, handler, api_fn, endpoint, verb, confidence)
11
- URL→Vue 映射: data/index/menu-routes.json (sys_menu 真实路由)
12
-
13
- 非侵入: 无 DuckDB / 无 traces 表 → 静默回退, 只 print 提示, 不报错。
14
-
15
- Usage (作为库):
16
- from page_probe import build_page_profile, render_profile_md
17
- md = render_profile_md(build_page_profile(keyword='保险'))
18
-
19
- Usage (CLI):
20
- python .qoder/scripts/page_probe.py --kw 保险
21
- python .qoder/scripts/page_probe.py --url /veh/vehicle/vehAffair/insurance
22
- """
23
-
24
- import json
25
- import os
26
- import sys
27
- from typing import Optional, List, Dict, Any
28
-
29
- if sys.platform == 'win32':
30
- try:
31
- sys.stdout.reconfigure(encoding='utf-8')
32
- except (AttributeError, IOError):
33
- pass
34
-
35
- _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
36
- if _THIS_DIR not in sys.path:
37
- sys.path.insert(0, _THIS_DIR)
38
- _COMMON = os.path.join(_THIS_DIR, 'common')
39
- if _COMMON not in sys.path:
40
- sys.path.insert(0, _COMMON)
41
-
42
- from common.paths import get_repo_root # noqa: E402
43
-
44
-
45
- # ============================================================
46
- # 路径定位
47
- # ============================================================
48
-
49
- def _kg_db_path(repo_root: Optional[str] = None) -> str:
50
- rr = repo_root or str(get_repo_root())
51
- return os.path.join(rr, 'data', 'index', 'kg.duckdb')
52
-
53
-
54
- def _menu_routes_path(repo_root: Optional[str] = None) -> str:
55
- rr = repo_root or str(get_repo_root())
56
- return os.path.join(rr, 'data', 'index', 'menu-routes.json')
57
-
58
-
59
- # ============================================================
60
- # DuckDB 查询 (只读, 失败静默回退)
61
- # ============================================================
62
-
63
- def _query_traces(vue_pattern: str, repo_root: Optional[str] = None) -> List[Dict[str, Any]]:
64
- """查 traces 表里 vue_file 匹配 pattern 的所有按钮+接口。
65
- pattern 用 LIKE (大小写不敏感, 因为下面转 lower)。
66
- 返回 [{vue_file, button, handler, api_fn, endpoint, verb, confidence}, ...]
67
- DuckDB / 无表 / 查询失败 → 返回 [] (静默)。
68
- """
69
- try:
70
- import duckdb
71
- except ImportError:
72
- return []
73
- db = _kg_db_path(repo_root)
74
- if not os.path.isfile(db):
75
- return []
76
- try:
77
- con = duckdb.connect(db, read_only=True)
78
- # 确认 traces 表存在
79
- tables = [r[0] for r in con.execute(
80
- "SELECT table_name FROM information_schema.tables WHERE table_schema='main'"
81
- ).fetchall()]
82
- if 'traces' not in tables:
83
- con.close()
84
- return []
85
- # LIKE 匹配 (vue_file 路径里有正斜杠, pattern 用 % 通配)
86
- pat = '%' + vue_pattern.lower() + '%'
87
- rows = con.execute(
88
- "SELECT vue_file, button, handler, api_fn, endpoint, verb, confidence "
89
- "FROM traces WHERE lower(vue_file) LIKE ? "
90
- "ORDER BY CASE confidence "
91
- " WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 "
92
- " WHEN 'deferred' THEN 3 ELSE 4 END, button",
93
- [pat],
94
- ).fetchall()
95
- con.close()
96
- return [
97
- {
98
- 'vue_file': r[0], 'button': r[1], 'handler': r[2],
99
- 'api_fn': r[3], 'endpoint': r[4], 'verb': r[5], 'confidence': r[6],
100
- }
101
- for r in rows
102
- if r[1] # button 不能为空
103
- ]
104
- except Exception:
105
- return []
106
-
107
-
108
- def _trace_stats(repo_root: Optional[str] = None) -> Optional[Dict[str, int]]:
109
- """traces 表的置信度统计 (用于画像末尾标注覆盖率)。失败返回 None。"""
110
- try:
111
- import duckdb
112
- except ImportError:
113
- return None
114
- db = _kg_db_path(repo_root)
115
- if not os.path.isfile(db):
116
- return None
117
- try:
118
- con = duckdb.connect(db, read_only=True)
119
- rows = con.execute(
120
- "SELECT confidence, COUNT(*) FROM traces GROUP BY confidence"
121
- ).fetchall()
122
- con.close()
123
- stats = {r[0]: int(r[1]) for r in rows}
124
- stats['total'] = sum(stats.values())
125
- resolved = stats.get('high', 0) + stats.get('medium', 0) + stats.get('low', 0)
126
- stats['resolved'] = resolved
127
- return stats
128
- except Exception:
129
- return None
130
-
131
-
132
- # ============================================================
133
- # URL → Vue 文件映射 (走菜单路由表)
134
- # ============================================================
135
-
136
- def _load_menu_routes(repo_root: Optional[str] = None) -> List[Dict[str, Any]]:
137
- """读 menu-routes.json。格式: {total, source, routes: [{name, path, component}, ...]}"""
138
- p = _menu_routes_path(repo_root)
139
- if not os.path.isfile(p):
140
- return []
141
- try:
142
- for enc in ('utf-8', 'utf-8-sig', 'gbk'):
143
- try:
144
- with open(p, encoding=enc) as f:
145
- data = json.load(f)
146
- # 支持 {routes: [...]} 包装和裸 [...] 两种
147
- if isinstance(data, dict):
148
- data = data.get('routes') or []
149
- return data if isinstance(data, list) else []
150
- except (OSError, IOError, UnicodeDecodeError, json.JSONDecodeError):
151
- continue
152
- except Exception:
153
- return []
154
- return []
155
-
156
-
157
- def _match_routes(keyword: str, routes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
158
- """用关键词匹配菜单 (name/path/component 任一包含关键词, 大小写不敏感)。
159
- 返回匹配的菜单项 [{name, path, component}], 按相关度排序。
160
- """
161
- kw = keyword.lower().strip()
162
- if not kw:
163
- return []
164
- hits = []
165
- for r in routes:
166
- name = (r.get('name') or r.get('menu_name') or '').lower()
167
- path = (r.get('path') or r.get('url') or '').lower()
168
- comp = (r.get('component') or r.get('component_path') or '').lower()
169
- # 完全匹配 name 优先级最高, 其次 path, 最后 component
170
- score = 0
171
- if kw == name:
172
- score = 100
173
- elif kw in name:
174
- score = 80
175
- elif kw in path:
176
- score = 60
177
- elif kw in comp:
178
- score = 40
179
- if score > 0:
180
- hits.append((score, r))
181
- hits.sort(key=lambda x: -x[0])
182
- return [h[1] for h in hits[:8]] # 最多 8 个, 避免过多
183
-
184
-
185
- def _route_to_vue_hint(route: Dict[str, Any]) -> str:
186
- """从菜单项推断 Vue 文件路径关键词 (用于 traces 查询)
187
- component 字段常是 'veh/vehicle/vehAffair/insurance/index' 这种相对路径。
188
- """
189
- comp = route.get('component') or route.get('component_path') or ''
190
- # 取 component 里最后两段 (模块名), 如 'insurance/record'
191
- parts = [p for p in comp.replace('\\', '/').split('/') if p and p != 'index']
192
- if len(parts) >= 2:
193
- return '/'.join(parts[-2:])
194
- if parts:
195
- return parts[-1]
196
- # fallback: path 的最后一段
197
- path = route.get('path') or route.get('url') or ''
198
- seg = [p for p in path.split('/') if p]
199
- return seg[-1] if seg else ''
200
-
201
-
202
- # ============================================================
203
- # 核心: 构建页面画像
204
- # ============================================================
205
-
206
- def build_page_profile(
207
- keyword: Optional[str] = None,
208
- url: Optional[str] = None,
209
- repo_root: Optional[str] = None,
210
- ) -> Dict[str, Any]:
211
- """构建页面画像。
212
- 输入: keyword (业务名如"保险") 或 url (/veh/insurance)。
213
- 返回:
214
- {
215
- 'ok': True/False,
216
- 'reason': '失败原因' (ok=False 时),
217
- 'pages': [
218
- {
219
- 'name': '保险记录', 'url': '/veh/...', 'vue_hint': 'insurance/record',
220
- 'buttons': [{button, handler, endpoint, verb, confidence, resolved}, ...],
221
- 'vue_files': [实际命中的 vue 文件路径, ...]
222
- }, ...
223
- ],
224
- 'stats': {total, resolved, ...} or None
225
- }
226
- """
227
- rr = repo_root or str(get_repo_root())
228
-
229
- # 1. 定位页面 (URL 直查 / 关键词查路由表)
230
- routes = _load_menu_routes(rr)
231
- matched_pages = [] # [{name, url, vue_hint}]
232
-
233
- if url:
234
- # URL 模式: 在路由表里找 path 匹配的, 找不到就用 url 自身做 vue 关键词
235
- url_lower = url.lower()
236
- hit = None
237
- for r in routes:
238
- rp = (r.get('path') or r.get('url') or '').lower()
239
- if rp and (rp == url_lower or url_lower in rp or rp in url_lower):
240
- hit = r
241
- break
242
- if hit:
243
- matched_pages.append({
244
- 'name': hit.get('name') or hit.get('menu_name') or url,
245
- 'url': hit.get('path') or hit.get('url') or url,
246
- 'vue_hint': _route_to_vue_hint(hit),
247
- })
248
- else:
249
- # 路由表没命中, 用 url 最后一段做 vue 关键词
250
- seg = [p for p in url.split('/') if p]
251
- matched_pages.append({
252
- 'name': url, 'url': url,
253
- 'vue_hint': seg[-1] if seg else url.strip('/'),
254
- })
255
- elif keyword:
256
- kw_hits = _match_routes(keyword, routes)
257
- # 整词匹配不到 → 尝试关键词的前缀子串 (保险异常管理 → 保险/保险异常)
258
- # 中文业务名常是"业务词+修饰词", 菜单名通常只含业务词主体
259
- if not kw_hits and len(keyword) >= 3:
260
- for sublen in (len(keyword) - 1, len(keyword) - 2, 2):
261
- if sublen < 2:
262
- break
263
- sub = keyword[:sublen]
264
- sub_hits = _match_routes(sub, routes)
265
- if sub_hits:
266
- kw_hits = sub_hits
267
- break
268
- if kw_hits:
269
- for r in kw_hits:
270
- matched_pages.append({
271
- 'name': r.get('name') or r.get('menu_name') or keyword,
272
- 'url': r.get('path') or r.get('url') or '',
273
- 'vue_hint': _route_to_vue_hint(r),
274
- })
275
- else:
276
- # 路由表没命中, 直接用关键词做 vue 关键词 (traces 表里搜)
277
- # 注意: 中文搜 vue 英文路径会失败, 但留着兜底 (英文关键词场景)
278
- matched_pages.append({
279
- 'name': keyword, 'url': '', 'vue_hint': keyword,
280
- })
281
- else:
282
- return {'ok': False, 'reason': '需要提供 --kw 或 --url', 'pages': [], 'stats': None}
283
-
284
- # 2. 每个页面查 traces
285
- pages_result = []
286
- for pg in matched_pages:
287
- hint = pg['vue_hint']
288
- if not hint:
289
- continue
290
- traces = _query_traces(hint, rr)
291
- if not traces:
292
- continue
293
- # 按 vue_file 分组 (一个 hint 可能命中多个 vue 文件, 各自列按钮)
294
- by_vue = {}
295
- for t in traces:
296
- by_vue.setdefault(t['vue_file'], []).append(t)
297
- for vue_file, btns in by_vue.items():
298
- # 去重: 同一按钮名 + 同一 endpoint 只留一个
299
- seen = set()
300
- dedup = []
301
- for b in btns:
302
- key = (b['button'], b.get('endpoint') or '')
303
- if key in seen:
304
- continue
305
- seen.add(key)
306
- dedup.append({
307
- 'button': b['button'],
308
- 'handler': b['handler'],
309
- 'endpoint': b['endpoint'],
310
- 'verb': b['verb'],
311
- 'confidence': b['confidence'],
312
- 'resolved': bool(b['endpoint'] and b['endpoint'] != 'None'),
313
- })
314
- pages_result.append({
315
- 'name': pg['name'],
316
- 'url': pg['url'],
317
- 'vue_file': vue_file,
318
- 'buttons': dedup,
319
- })
320
-
321
- stats = _trace_stats(rr)
322
-
323
- if not pages_result:
324
- return {
325
- 'ok': False,
326
- 'reason': '知识图谱里没找到匹配的页面按钮 (可能页面未被索引, 或关键词不准)',
327
- 'pages': [],
328
- 'stats': stats,
329
- }
330
- return {'ok': True, 'pages': pages_result, 'stats': stats}
331
-
332
-
333
- # ============================================================
334
- # 渲染: Markdown 画像 (给测试人员看 / 写入 test-handoff.md)
335
- # ============================================================
336
-
337
- def render_profile_md(profile: Dict[str, Any]) -> str:
338
- """把画像渲染成 Markdown。"""
339
- if not profile.get('ok'):
340
- lines = ['> ' + (profile.get('reason') or '无画像数据')]
341
- stats = profile.get('stats')
342
- if stats:
343
- lines.append('> (知识图谱 traces 覆盖率 %.0f%%, 未覆盖的交互需现场确认)'
344
- % (100.0 * stats.get('resolved', 0) / max(1, stats.get('total', 1))))
345
- return '\n'.join(lines)
346
-
347
- lines = []
348
- total_buttons = 0
349
- for pg in profile['pages']:
350
- btns = pg['buttons']
351
- total_buttons += len(btns)
352
- lines.append('### %s' % pg['name'])
353
- if pg.get('url'):
354
- lines.append('路由: `%s`' % pg['url'])
355
- # vue_file 只取 data/code/ 之后的相对路径, 更短
356
- vf = pg.get('vue_file', '')
357
- vf_rel = vf.split('/data/code/')[-1] if '/data/code/' in vf else vf
358
- lines.append('Vue: `%s`' % vf_rel)
359
- lines.append('')
360
- resolved_btns = [b for b in btns if b['resolved']]
361
- unresolved_btns = [b for b in btns if not b['resolved']]
362
-
363
- if resolved_btns:
364
- lines.append('| 按钮 | handler | 接口 | 方法 | 置信度 |')
365
- lines.append('|------|---------|------|------|--------|')
366
- for b in resolved_btns:
367
- ep = b['endpoint'] or '?'
368
- lines.append('| %s | %s | %s | %s | %s |' % (
369
- b['button'], b['handler'] or '?', ep, (b['verb'] or '?').upper(),
370
- b['confidence']))
371
- lines.append('')
372
-
373
- if unresolved_btns:
374
- lines.append('**未解析到接口的按钮** (交互意图已知, 接口需现场确认):')
375
- for b in unresolved_btns:
376
- lines.append('- %s → %s (%s)' % (
377
- b['button'], b['handler'] or '?', b['confidence']))
378
- lines.append('')
379
-
380
- # 测试建议 (按按钮类型自动给提示)
381
- lines.append('**测试建议:**')
382
- sugg = _test_suggestions(btns)
383
- for s in sugg:
384
- lines.append('- %s' % s)
385
- lines.append('')
386
-
387
- stats = profile.get('stats')
388
- if stats:
389
- cov = 100.0 * stats.get('resolved', 0) / max(1, stats.get('total', 1))
390
- lines.append('> 📊 知识图谱 traces 覆盖率 %.0f%% (resolved %d/%d)。'
391
- '未覆盖的按钮交互需现场点击确认。' % (cov, stats.get('resolved', 0), stats.get('total', 1)))
392
- return '\n'.join(lines)
393
-
394
-
395
- def _test_suggestions(btns: List[Dict[str, Any]]) -> List[str]:
396
- """根据按钮文本自动生成测试建议。"""
397
- sugg = []
398
- btn_texts = [b['button'] for b in btns]
399
- btn_set = set(btn_texts)
400
- # 按常见按钮语义给建议
401
- if any(k in btn_set for k in ('新增', '添加', '新建', '创建')):
402
- sugg.append('新增类: fill 表单必填项 → click 新增按钮 → 断言接口返回成功 + 列表出现新记录')
403
- if any(k in btn_set for k in ('编辑', '修改', '更新')):
404
- sugg.append('编辑类: 选中记录 click 编辑 断言表单回填 + 保存后接口 UPDATE 调用')
405
- if any(k in btn_set for k in ('删除', '移除')):
406
- sugg.append('删除类: click 删除 → 二次确认弹窗 → 断言接口 DELETE 调用 + 列表记录消失')
407
- if any(k in btn_set for k in ('导出', '下载')):
408
- sugg.append('导出类: click 导出断言下载触发 (或接口 export 调用 + 文件生成)')
409
- if any(k in btn_set for k in ('查询', '搜索', '筛选', '检索')):
410
- sugg.append('查询类: fill 筛选条件 → click 查询断言列表按条件刷新')
411
- if any(k in btn_set for k in ('导入', '上传')):
412
- sugg.append('导入类: upload 文件断言解析结果 + 接口 import 调用')
413
- if any(k in btn_set for k in ('提交', '保存', '确认')):
414
- sugg.append('提交类: 填完表单click 提交 断言接口成功 + 状态流转')
415
- # 异常场景 (通用)
416
- if sugg:
417
- sugg.append('异常场景: 必填项留空提交 断言校验提示; 接口超时/失败 断言错误提示')
418
- else:
419
- sugg.append('通用: 逐个点击按钮 断言对应接口被调用 + 页面反馈正确')
420
- return sugg
421
-
422
-
423
- # ============================================================
424
- # CLI
425
- # ============================================================
426
-
427
- def main(argv=None):
428
- import argparse
429
- p = argparse.ArgumentParser(
430
- prog='page_probe.py',
431
- description='查页面按钮+接口画像 (开发→测试交接物)',
432
- )
433
- g = p.add_mutually_exclusive_group(required=True)
434
- g.add_argument('--kw', help='业务关键词 (如 保险 / 排班)')
435
- g.add_argument('--url', help='页面 URL (如 /veh/vehicle/vehAffair/insurance)')
436
- p.add_argument('--repo', default=None, help='仓库根目录 (默认自动检测)')
437
- args = p.parse_args(argv)
438
-
439
- profile = build_page_profile(keyword=args.kw, url=args.url, repo_root=args.repo)
440
-
441
- if not profile.get('ok'):
442
- print('## 页面画像: %s' % (args.kw or args.url))
443
- print()
444
- print(render_profile_md(profile))
445
- print()
446
- sys.exit(0)
447
-
448
- print('## 页面画像: %s' % (args.kw or args.url))
449
- print()
450
- print(render_profile_md(profile))
451
- n_pages = len(profile['pages'])
452
- n_btns = sum(len(pg['buttons']) for pg in profile['pages'])
453
- print()
454
- print('[page] %d 个页面, %d 个交互点' % (n_pages, n_btns))
455
- sys.exit(0)
456
-
457
-
458
- if __name__ == '__main__':
459
- main()
1
+ # -*- coding: utf-8 -*-
2
+ # v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
3
+ import os as _o, sys as _s
4
+ _cp = _o.path.join(_o.path.dirname(_o.path.dirname(_o.path.abspath(__file__))), 'common')
5
+ if _cp not in _s.path: _s.path.insert(0, _cp)
6
+ from bootstrap import setup; setup()
7
+
8
+ """
9
+ page_probe.py - 页面按钮+接口画像 (开发→测试交接物)
10
+
11
+ DuckDB 知识图谱的 traces 表查"某个页面有哪些按钮、点按钮调哪个接口",
12
+ 输出一份测试人员能照着写用例的画像。解决痛点:开发写完代码交接给测试时,
13
+ 测试不用再满仓库找页面/按钮/接口。
14
+
15
+ 数据源: data/index/kg.duckdb 的 traces 表
16
+ (vue_file, button, handler, api_fn, endpoint, verb, confidence)
17
+ URL→Vue 映射: data/index/menu-routes.json (sys_menu 真实路由)
18
+
19
+ 非侵入: 无 DuckDB / 无 traces 表 → 静默回退, 只 print 提示, 不报错。
20
+
21
+ Usage (作为库):
22
+ from page_probe import build_page_profile, render_profile_md
23
+ md = render_profile_md(build_page_profile(keyword='保险'))
24
+
25
+ Usage (CLI):
26
+ python .qoder/scripts/page_probe.py --kw 保险
27
+ python .qoder/scripts/page_probe.py --url /veh/vehicle/vehAffair/insurance
28
+ """
29
+
30
+ import json
31
+ import os
32
+ import sys
33
+ from typing import Optional, List, Dict, Any
34
+
35
+ if sys.platform == 'win32':
36
+ try:
37
+ sys.stdout.reconfigure(encoding='utf-8')
38
+ except (AttributeError, IOError):
39
+ pass
40
+
41
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
42
+ if _THIS_DIR not in sys.path:
43
+ sys.path.insert(0, _THIS_DIR)
44
+ _COMMON = os.path.join(_THIS_DIR, 'common')
45
+ if _COMMON not in sys.path:
46
+ sys.path.insert(0, _COMMON)
47
+
48
+ from common.paths import get_repo_root # noqa: E402
49
+
50
+
51
+ # ============================================================
52
+ # 路径定位
53
+ # ============================================================
54
+
55
+ def _kg_db_path(repo_root: Optional[str] = None) -> str:
56
+ rr = repo_root or str(get_repo_root())
57
+ return os.path.join(rr, 'data', 'index', 'kg.duckdb')
58
+
59
+
60
+ def _menu_routes_path(repo_root: Optional[str] = None) -> str:
61
+ rr = repo_root or str(get_repo_root())
62
+ return os.path.join(rr, 'data', 'index', 'menu-routes.json')
63
+
64
+
65
+ # ============================================================
66
+ # DuckDB 查询 (只读, 失败静默回退)
67
+ # ============================================================
68
+
69
+ def _query_traces(vue_pattern: str, repo_root: Optional[str] = None) -> List[Dict[str, Any]]:
70
+ """查 traces 表里 vue_file 匹配 pattern 的所有按钮+接口。
71
+ pattern 用 LIKE (大小写不敏感, 因为下面转 lower)。
72
+ 返回 [{vue_file, button, handler, api_fn, endpoint, verb, confidence}, ...]
73
+ DuckDB / 无表 / 查询失败 → 返回 [] (静默)
74
+ """
75
+ try:
76
+ import duckdb
77
+ except ImportError:
78
+ return []
79
+ db = _kg_db_path(repo_root)
80
+ if not os.path.isfile(db):
81
+ return []
82
+ try:
83
+ con = duckdb.connect(db, read_only=True)
84
+ # 确认 traces 表存在
85
+ tables = [r[0] for r in con.execute(
86
+ "SELECT table_name FROM information_schema.tables WHERE table_schema='main'"
87
+ ).fetchall()]
88
+ if 'traces' not in tables:
89
+ con.close()
90
+ return []
91
+ # LIKE 匹配 (vue_file 路径里有正斜杠, pattern % 通配)
92
+ pat = '%' + vue_pattern.lower() + '%'
93
+ rows = con.execute(
94
+ "SELECT vue_file, button, handler, api_fn, endpoint, verb, confidence "
95
+ "FROM traces WHERE lower(vue_file) LIKE ? "
96
+ "ORDER BY CASE confidence "
97
+ " WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 "
98
+ " WHEN 'deferred' THEN 3 ELSE 4 END, button",
99
+ [pat],
100
+ ).fetchall()
101
+ con.close()
102
+ return [
103
+ {
104
+ 'vue_file': r[0], 'button': r[1], 'handler': r[2],
105
+ 'api_fn': r[3], 'endpoint': r[4], 'verb': r[5], 'confidence': r[6],
106
+ }
107
+ for r in rows
108
+ if r[1] # button 不能为空
109
+ ]
110
+ except Exception:
111
+ return []
112
+
113
+
114
+ def _trace_stats(repo_root: Optional[str] = None) -> Optional[Dict[str, int]]:
115
+ """traces 表的置信度统计 (用于画像末尾标注覆盖率)。失败返回 None。"""
116
+ try:
117
+ import duckdb
118
+ except ImportError:
119
+ return None
120
+ db = _kg_db_path(repo_root)
121
+ if not os.path.isfile(db):
122
+ return None
123
+ try:
124
+ con = duckdb.connect(db, read_only=True)
125
+ rows = con.execute(
126
+ "SELECT confidence, COUNT(*) FROM traces GROUP BY confidence"
127
+ ).fetchall()
128
+ con.close()
129
+ stats = {r[0]: int(r[1]) for r in rows}
130
+ stats['total'] = sum(stats.values())
131
+ resolved = stats.get('high', 0) + stats.get('medium', 0) + stats.get('low', 0)
132
+ stats['resolved'] = resolved
133
+ return stats
134
+ except Exception:
135
+ return None
136
+
137
+
138
+ # ============================================================
139
+ # URL → Vue 文件映射 (走菜单路由表)
140
+ # ============================================================
141
+
142
+ def _load_menu_routes(repo_root: Optional[str] = None) -> List[Dict[str, Any]]:
143
+ """读 menu-routes.json。格式: {total, source, routes: [{name, path, component}, ...]}"""
144
+ p = _menu_routes_path(repo_root)
145
+ if not os.path.isfile(p):
146
+ return []
147
+ try:
148
+ for enc in ('utf-8', 'utf-8-sig', 'gbk'):
149
+ try:
150
+ with open(p, encoding=enc) as f:
151
+ data = json.load(f)
152
+ # 支持 {routes: [...]} 包装和裸 [...] 两种
153
+ if isinstance(data, dict):
154
+ data = data.get('routes') or []
155
+ return data if isinstance(data, list) else []
156
+ except (OSError, IOError, UnicodeDecodeError, json.JSONDecodeError):
157
+ continue
158
+ except Exception:
159
+ return []
160
+ return []
161
+
162
+
163
+ def _match_routes(keyword: str, routes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
164
+ """用关键词匹配菜单 (name/path/component 任一包含关键词, 大小写不敏感)。
165
+ 返回匹配的菜单项 [{name, path, component}], 按相关度排序。
166
+ """
167
+ kw = keyword.lower().strip()
168
+ if not kw:
169
+ return []
170
+ hits = []
171
+ for r in routes:
172
+ name = (r.get('name') or r.get('menu_name') or '').lower()
173
+ path = (r.get('path') or r.get('url') or '').lower()
174
+ comp = (r.get('component') or r.get('component_path') or '').lower()
175
+ # 完全匹配 name 优先级最高, 其次 path, 最后 component
176
+ score = 0
177
+ if kw == name:
178
+ score = 100
179
+ elif kw in name:
180
+ score = 80
181
+ elif kw in path:
182
+ score = 60
183
+ elif kw in comp:
184
+ score = 40
185
+ if score > 0:
186
+ hits.append((score, r))
187
+ hits.sort(key=lambda x: -x[0])
188
+ return [h[1] for h in hits[:8]] # 最多 8 个, 避免过多
189
+
190
+
191
+ def _route_to_vue_hint(route: Dict[str, Any]) -> str:
192
+ """从菜单项推断 Vue 文件路径关键词 (用于 traces 查询)。
193
+ component 字段常是 'veh/vehicle/vehAffair/insurance/index' 这种相对路径。
194
+ """
195
+ comp = route.get('component') or route.get('component_path') or ''
196
+ # component 里最后两段 (模块名), 如 'insurance/record'
197
+ parts = [p for p in comp.replace('\\', '/').split('/') if p and p != 'index']
198
+ if len(parts) >= 2:
199
+ return '/'.join(parts[-2:])
200
+ if parts:
201
+ return parts[-1]
202
+ # fallback: 用 path 的最后一段
203
+ path = route.get('path') or route.get('url') or ''
204
+ seg = [p for p in path.split('/') if p]
205
+ return seg[-1] if seg else ''
206
+
207
+
208
+ # ============================================================
209
+ # 核心: 构建页面画像
210
+ # ============================================================
211
+
212
+ def build_page_profile(
213
+ keyword: Optional[str] = None,
214
+ url: Optional[str] = None,
215
+ repo_root: Optional[str] = None,
216
+ ) -> Dict[str, Any]:
217
+ """构建页面画像。
218
+ 输入: keyword (业务名如"保险") 或 url (/veh/insurance)。
219
+ 返回:
220
+ {
221
+ 'ok': True/False,
222
+ 'reason': '失败原因' (ok=False 时),
223
+ 'pages': [
224
+ {
225
+ 'name': '保险记录', 'url': '/veh/...', 'vue_hint': 'insurance/record',
226
+ 'buttons': [{button, handler, endpoint, verb, confidence, resolved}, ...],
227
+ 'vue_files': [实际命中的 vue 文件路径, ...]
228
+ }, ...
229
+ ],
230
+ 'stats': {total, resolved, ...} or None
231
+ }
232
+ """
233
+ rr = repo_root or str(get_repo_root())
234
+
235
+ # 1. 定位页面 (URL 直查 / 关键词查路由表)
236
+ routes = _load_menu_routes(rr)
237
+ matched_pages = [] # [{name, url, vue_hint}]
238
+
239
+ if url:
240
+ # URL 模式: 在路由表里找 path 匹配的, 找不到就用 url 自身做 vue 关键词
241
+ url_lower = url.lower()
242
+ hit = None
243
+ for r in routes:
244
+ rp = (r.get('path') or r.get('url') or '').lower()
245
+ if rp and (rp == url_lower or url_lower in rp or rp in url_lower):
246
+ hit = r
247
+ break
248
+ if hit:
249
+ matched_pages.append({
250
+ 'name': hit.get('name') or hit.get('menu_name') or url,
251
+ 'url': hit.get('path') or hit.get('url') or url,
252
+ 'vue_hint': _route_to_vue_hint(hit),
253
+ })
254
+ else:
255
+ # 路由表没命中, 用 url 最后一段做 vue 关键词
256
+ seg = [p for p in url.split('/') if p]
257
+ matched_pages.append({
258
+ 'name': url, 'url': url,
259
+ 'vue_hint': seg[-1] if seg else url.strip('/'),
260
+ })
261
+ elif keyword:
262
+ kw_hits = _match_routes(keyword, routes)
263
+ # 整词匹配不到 → 尝试关键词的前缀子串 (保险异常管理 → 保险/保险异常)
264
+ # 中文业务名常是"业务词+修饰词", 菜单名通常只含业务词主体
265
+ if not kw_hits and len(keyword) >= 3:
266
+ for sublen in (len(keyword) - 1, len(keyword) - 2, 2):
267
+ if sublen < 2:
268
+ break
269
+ sub = keyword[:sublen]
270
+ sub_hits = _match_routes(sub, routes)
271
+ if sub_hits:
272
+ kw_hits = sub_hits
273
+ break
274
+ if kw_hits:
275
+ for r in kw_hits:
276
+ matched_pages.append({
277
+ 'name': r.get('name') or r.get('menu_name') or keyword,
278
+ 'url': r.get('path') or r.get('url') or '',
279
+ 'vue_hint': _route_to_vue_hint(r),
280
+ })
281
+ else:
282
+ # 路由表没命中, 直接用关键词做 vue 关键词 (traces 表里搜)
283
+ # 注意: 中文搜 vue 英文路径会失败, 但留着兜底 (英文关键词场景)
284
+ matched_pages.append({
285
+ 'name': keyword, 'url': '', 'vue_hint': keyword,
286
+ })
287
+ else:
288
+ return {'ok': False, 'reason': '需要提供 --kw 或 --url', 'pages': [], 'stats': None}
289
+
290
+ # 2. 每个页面查 traces
291
+ pages_result = []
292
+ for pg in matched_pages:
293
+ hint = pg['vue_hint']
294
+ if not hint:
295
+ continue
296
+ traces = _query_traces(hint, rr)
297
+ if not traces:
298
+ continue
299
+ # vue_file 分组 (一个 hint 可能命中多个 vue 文件, 各自列按钮)
300
+ by_vue = {}
301
+ for t in traces:
302
+ by_vue.setdefault(t['vue_file'], []).append(t)
303
+ for vue_file, btns in by_vue.items():
304
+ # 去重: 同一按钮名 + 同一 endpoint 只留一个
305
+ seen = set()
306
+ dedup = []
307
+ for b in btns:
308
+ key = (b['button'], b.get('endpoint') or '')
309
+ if key in seen:
310
+ continue
311
+ seen.add(key)
312
+ dedup.append({
313
+ 'button': b['button'],
314
+ 'handler': b['handler'],
315
+ 'endpoint': b['endpoint'],
316
+ 'verb': b['verb'],
317
+ 'confidence': b['confidence'],
318
+ 'resolved': bool(b['endpoint'] and b['endpoint'] != 'None'),
319
+ })
320
+ pages_result.append({
321
+ 'name': pg['name'],
322
+ 'url': pg['url'],
323
+ 'vue_file': vue_file,
324
+ 'buttons': dedup,
325
+ })
326
+
327
+ stats = _trace_stats(rr)
328
+
329
+ if not pages_result:
330
+ return {
331
+ 'ok': False,
332
+ 'reason': '知识图谱里没找到匹配的页面按钮 (可能页面未被索引, 或关键词不准)',
333
+ 'pages': [],
334
+ 'stats': stats,
335
+ }
336
+ return {'ok': True, 'pages': pages_result, 'stats': stats}
337
+
338
+
339
+ # ============================================================
340
+ # 渲染: Markdown 画像 (给测试人员看 / 写入 test-handoff.md)
341
+ # ============================================================
342
+
343
+ def render_profile_md(profile: Dict[str, Any]) -> str:
344
+ """把画像渲染成 Markdown。"""
345
+ if not profile.get('ok'):
346
+ lines = ['> ' + (profile.get('reason') or '无画像数据')]
347
+ stats = profile.get('stats')
348
+ if stats:
349
+ lines.append('> (知识图谱 traces 覆盖率 %.0f%%, 未覆盖的交互需现场确认)'
350
+ % (100.0 * stats.get('resolved', 0) / max(1, stats.get('total', 1))))
351
+ return '\n'.join(lines)
352
+
353
+ lines = []
354
+ total_buttons = 0
355
+ for pg in profile['pages']:
356
+ btns = pg['buttons']
357
+ total_buttons += len(btns)
358
+ lines.append('### %s' % pg['name'])
359
+ if pg.get('url'):
360
+ lines.append('路由: `%s`' % pg['url'])
361
+ # vue_file 只取 data/code/ 之后的相对路径, 更短
362
+ vf = pg.get('vue_file', '')
363
+ vf_rel = vf.split('/data/code/')[-1] if '/data/code/' in vf else vf
364
+ lines.append('Vue: `%s`' % vf_rel)
365
+ lines.append('')
366
+ resolved_btns = [b for b in btns if b['resolved']]
367
+ unresolved_btns = [b for b in btns if not b['resolved']]
368
+
369
+ if resolved_btns:
370
+ lines.append('| 按钮 | handler | 接口 | 方法 | 置信度 |')
371
+ lines.append('|------|---------|------|------|--------|')
372
+ for b in resolved_btns:
373
+ ep = b['endpoint'] or '?'
374
+ lines.append('| %s | %s | %s | %s | %s |' % (
375
+ b['button'], b['handler'] or '?', ep, (b['verb'] or '?').upper(),
376
+ b['confidence']))
377
+ lines.append('')
378
+
379
+ if unresolved_btns:
380
+ lines.append('**未解析到接口的按钮** (交互意图已知, 接口需现场确认):')
381
+ for b in unresolved_btns:
382
+ lines.append('- %s → %s (%s)' % (
383
+ b['button'], b['handler'] or '?', b['confidence']))
384
+ lines.append('')
385
+
386
+ # 测试建议 (按按钮类型自动给提示)
387
+ lines.append('**测试建议:**')
388
+ sugg = _test_suggestions(btns)
389
+ for s in sugg:
390
+ lines.append('- %s' % s)
391
+ lines.append('')
392
+
393
+ stats = profile.get('stats')
394
+ if stats:
395
+ cov = 100.0 * stats.get('resolved', 0) / max(1, stats.get('total', 1))
396
+ lines.append('> 📊 知识图谱 traces 覆盖率 %.0f%% (resolved %d/%d)。'
397
+ '未覆盖的按钮交互需现场点击确认。' % (cov, stats.get('resolved', 0), stats.get('total', 1)))
398
+ return '\n'.join(lines)
399
+
400
+
401
+ def _test_suggestions(btns: List[Dict[str, Any]]) -> List[str]:
402
+ """根据按钮文本自动生成测试建议。"""
403
+ sugg = []
404
+ btn_texts = [b['button'] for b in btns]
405
+ btn_set = set(btn_texts)
406
+ # 按常见按钮语义给建议
407
+ if any(k in btn_set for k in ('新增', '添加', '新建', '创建')):
408
+ sugg.append('新增类: fill 表单必填项click 新增按钮 断言接口返回成功 + 列表出现新记录')
409
+ if any(k in btn_set for k in ('编辑', '修改', '更新')):
410
+ sugg.append('编辑类: 选中记录 → click 编辑断言表单回填 + 保存后接口 UPDATE 调用')
411
+ if any(k in btn_set for k in ('删除', '移除')):
412
+ sugg.append('删除类: click 删除二次确认弹窗 断言接口 DELETE 调用 + 列表记录消失')
413
+ if any(k in btn_set for k in ('导出', '下载')):
414
+ sugg.append('导出类: click 导出 断言下载触发 (或接口 export 调用 + 文件生成)')
415
+ if any(k in btn_set for k in ('查询', '搜索', '筛选', '检索')):
416
+ sugg.append('查询类: fill 筛选条件 → click 查询 → 断言列表按条件刷新')
417
+ if any(k in btn_set for k in ('导入', '上传')):
418
+ sugg.append('导入类: upload 文件 → 断言解析结果 + 接口 import 调用')
419
+ if any(k in btn_set for k in ('提交', '保存', '确认')):
420
+ sugg.append('提交类: 填完表单 → click 提交 → 断言接口成功 + 状态流转')
421
+ # 异常场景 (通用)
422
+ if sugg:
423
+ sugg.append('异常场景: 必填项留空提交 → 断言校验提示; 接口超时/失败 → 断言错误提示')
424
+ else:
425
+ sugg.append('通用: 逐个点击按钮 → 断言对应接口被调用 + 页面反馈正确')
426
+ return sugg
427
+
428
+
429
+ # ============================================================
430
+ # CLI
431
+ # ============================================================
432
+
433
+ def main(argv=None):
434
+ import argparse
435
+ p = argparse.ArgumentParser(
436
+ prog='page_probe.py',
437
+ description='查页面按钮+接口画像 (开发→测试交接物)',
438
+ )
439
+ g = p.add_mutually_exclusive_group(required=True)
440
+ g.add_argument('--kw', help='业务关键词 (如 保险 / 排班)')
441
+ g.add_argument('--url', help='页面 URL (如 /veh/vehicle/vehAffair/insurance)')
442
+ p.add_argument('--repo', default=None, help='仓库根目录 (默认自动检测)')
443
+ args = p.parse_args(argv)
444
+
445
+ profile = build_page_profile(keyword=args.kw, url=args.url, repo_root=args.repo)
446
+
447
+ if not profile.get('ok'):
448
+ print('## 页面画像: %s' % (args.kw or args.url))
449
+ print()
450
+ print(render_profile_md(profile))
451
+ print()
452
+ sys.exit(0)
453
+
454
+ print('## 页面画像: %s' % (args.kw or args.url))
455
+ print()
456
+ print(render_profile_md(profile))
457
+ n_pages = len(profile['pages'])
458
+ n_btns = sum(len(pg['buttons']) for pg in profile['pages'])
459
+ print()
460
+ print('[page] %d 个页面, %d 个交互点' % (n_pages, n_btns))
461
+ sys.exit(0)
462
+
463
+
464
+ if __name__ == '__main__':
465
+ main()