Skip to content

Commit b6c8199

Browse files
committed
增加自动生成测试用例
1 parent 34d0d72 commit b6c8199

3 files changed

Lines changed: 293 additions & 14 deletions

File tree

.claude/settings.local.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"permissions": {
3+
"allow": ["Bash(node build.cjs)", "Bash(node -e ':*)", "Bash(node _patch.cjs)"]
4+
}
5+
}

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
1. 解除20条的显示限制,展示所有补全条目
66
2. 动态适配新的上游依赖
77
3. 增加区分方法所属工作区
8+
4. 增加AI自动生成指定API的测试用例
89

910
# 2.3.2
1011

iframe/script/User_config/ACE_Config.js

Lines changed: 287 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -879,7 +879,7 @@ function ImportFile(editor) {
879879
}
880880

881881
/**
882-
* 注入右键菜单项:「跳转方法文档」
882+
* 注入右键菜单项:「跳转方法文档」+「生成测试用例」
883883
* @param {Object} editor - ACE 编辑器实例
884884
* @param {Array<string>} fullMethodPaths - 完整方法路径列表,如 ['eda.DMT_Board.copyBoard', ...]
885885
*/
@@ -929,24 +929,40 @@ function injectContextMenuJumpToDocs(editor, fullMethodPaths) {
929929
user-select: none;
930930
`;
931931

932-
const item = document.createElement('div');
933-
item.textContent = matchedMethod ? '跳转方法文档' : '未找到可跳转的方法';
934-
item.style.padding = '6px 12px';
935-
item.style.cursor = matchedMethod ? 'pointer' : 'default';
936-
item.style.opacity = matchedMethod ? '1' : '0.6';
932+
// ── 菜单项辅助函数 ────────────────────────────────
933+
function createMenuItem(text, enabled, onClick) {
934+
const el = document.createElement('div');
935+
el.textContent = text;
936+
el.style.padding = '6px 12px';
937+
el.style.cursor = enabled ? 'pointer' : 'default';
938+
el.style.opacity = enabled ? '1' : '0.6';
939+
if (enabled) {
940+
el.onmouseenter = () => (el.style.background = menuHover);
941+
el.onmouseleave = () => (el.style.background = '');
942+
el.onclick = () => {
943+
closeMenu();
944+
onClick();
945+
};
946+
}
947+
return el;
948+
}
937949

938-
if (matchedMethod) {
939-
item.onmouseenter = () => (item.style.background = menuHover);
940-
item.onmouseleave = () => (item.style.background = '');
941-
item.onclick = () => {
950+
// 1. 跳转方法文档
951+
menu.appendChild(
952+
createMenuItem(matchedMethod ? '跳转方法文档' : '未找到可跳转的方法', !!matchedMethod, () => {
942953
let clean = matchedMethod.startsWith('eda.') ? matchedMethod.substring(4) : matchedMethod;
943954
const url = `https://prodocs.lceda.cn/cn/api/reference/pro-api.${clean.toLowerCase()}.html`;
944955
window.open(url, '_blank');
945-
closeMenu();
946-
};
947-
}
956+
}),
957+
);
958+
959+
// 2. 生成测试用例
960+
menu.appendChild(
961+
createMenuItem('生成测试用例', !!matchedMethod, () => {
962+
generateTestCase(editor, matchedMethod);
963+
}),
964+
);
948965

949-
menu.appendChild(item);
950966
document.body.appendChild(menu);
951967

952968
// 关闭菜单函数
@@ -963,6 +979,263 @@ function injectContextMenuJumpToDocs(editor, fullMethodPaths) {
963979
});
964980
}
965981

982+
// ============================================================
983+
// 生成测试用例(通过 AI 生成单例/组合用法示例)
984+
// ============================================================
985+
986+
/**
987+
* 从 edcode 中查找方法信息
988+
*/
989+
function _findMethodInfo(methodPath) {
990+
if (typeof edcode === 'undefined') return null;
991+
return edcode.find((e) => e.methodPath === methodPath) || null;
992+
}
993+
994+
/**
995+
* 将一个 edcode 条目格式化为可读的描述文本
996+
*/
997+
function _formatMethodDoc(info) {
998+
if (!info) return '';
999+
let doc = `方法路径: ${info.methodPath}\n`;
1000+
doc += `描述: ${info.description || '无'}\n`;
1001+
if (info.parameters && info.parameters.length > 0) {
1002+
doc += '参数:\n';
1003+
info.parameters.forEach((p) => {
1004+
doc += ` - ${p.name}: ${p.description || ''}\n`;
1005+
});
1006+
} else {
1007+
doc += '参数: 无(可直接调用)\n';
1008+
}
1009+
doc += `返回值: ${info.returns || '无'}\n`;
1010+
doc += `备注: ${info.remarks || '无'}\n`;
1011+
return doc;
1012+
}
1013+
1014+
/**
1015+
* 显示 toast(封装,兼容 eda 不存在的情况)
1016+
*/
1017+
function _toast(msg, type, duration) {
1018+
if (typeof eda !== 'undefined' && eda.sys_Message) {
1019+
eda.sys_Message.showToastMessage(msg, type || 'info', duration || 2);
1020+
}
1021+
}
1022+
1023+
/**
1024+
* 本地依赖分析:扫描参数描述中的类名引用和关键词,匹配已知方法
1025+
* 返回按依赖顺序排列的方法信息数组(叶子节点在前)
1026+
*/
1027+
function _traceDependencies(methodPath, visited) {
1028+
if (!visited) visited = new Set();
1029+
if (visited.has(methodPath)) return [];
1030+
visited.add(methodPath);
1031+
1032+
const info = _findMethodInfo(methodPath);
1033+
if (!info || !info.parameters || info.parameters.length === 0) return [];
1034+
1035+
const deps = [];
1036+
const allMethods = edcode.filter((e) => (e.methodPath.match(/\./g) || []).length >= 2);
1037+
1038+
for (const param of info.parameters) {
1039+
const desc = (param.description || '').toLowerCase();
1040+
const name = (param.name || '').toLowerCase();
1041+
1042+
for (const candidate of allMethods) {
1043+
if (candidate.methodPath === methodPath) continue;
1044+
if (!candidate.returns) continue;
1045+
1046+
const candidateReturns = (candidate.returns || '').toLowerCase();
1047+
const candidateMethodName = candidate.methodPath.split('.').pop().toLowerCase();
1048+
const candidateClassName = candidate.methodPath.split('.')[1] || '';
1049+
const classNameLower = candidateClassName.toLowerCase();
1050+
1051+
let matched = false;
1052+
1053+
// 参数描述中引用了候选方法所属的类名(如 LIB_LibrariesList)
1054+
if (classNameLower && desc.includes(classNameLower)) {
1055+
if (name.includes('uuid') && candidateReturns.includes('uuid')) matched = true;
1056+
if (name.includes('list') && candidateReturns.includes('列表')) matched = true;
1057+
if (name.includes('name') && candidateReturns.includes('名称')) matched = true;
1058+
if (desc.includes('获取') || desc.includes('接口')) matched = true;
1059+
}
1060+
1061+
// 参数描述中直接出现候选方法名
1062+
if (candidateMethodName.length > 3 && desc.includes(candidateMethodName)) {
1063+
matched = true;
1064+
}
1065+
1066+
if (matched && !visited.has(candidate.methodPath)) {
1067+
const subDeps = _traceDependencies(candidate.methodPath, visited);
1068+
deps.push(...subDeps, candidate);
1069+
break; // 每个参数只匹配一个依赖
1070+
}
1071+
}
1072+
}
1073+
1074+
return deps;
1075+
}
1076+
1077+
/**
1078+
* 执行依赖追溯并逐步显示 toast 进度
1079+
* 返回去重后的有序依赖链(叶子在前)
1080+
*/
1081+
async function _traceWithProgress(methodPath) {
1082+
const info = _findMethodInfo(methodPath);
1083+
if (!info) return [];
1084+
1085+
_toast(`[1/3] 分析 ${info.description || methodPath} 的参数依赖...`, 'info', 2);
1086+
await new Promise((r) => setTimeout(r, 300));
1087+
1088+
const rawDeps = _traceDependencies(methodPath);
1089+
1090+
const seen = new Set();
1091+
const uniqueDeps = [];
1092+
for (const dep of rawDeps) {
1093+
if (!seen.has(dep.methodPath)) {
1094+
seen.add(dep.methodPath);
1095+
uniqueDeps.push(dep);
1096+
}
1097+
}
1098+
1099+
if (uniqueDeps.length === 0) {
1100+
_toast(`[2/3] ${info.description} 无外部依赖,直接生成`, 'info', 2);
1101+
} else {
1102+
for (let i = 0; i < uniqueDeps.length; i++) {
1103+
const dep = uniqueDeps[i];
1104+
_toast(`[2/3] 追溯依赖 (${i + 1}/${uniqueDeps.length}): ${dep.methodPath}${dep.description || ''}`, 'info', 2);
1105+
await new Promise((r) => setTimeout(r, 400));
1106+
}
1107+
}
1108+
1109+
return uniqueDeps;
1110+
}
1111+
1112+
/**
1113+
* 构建发送给 AI 的完整 prompt(仅包含目标方法 + 已追溯的依赖链)
1114+
*/
1115+
function _buildTestCasePrompt(methodPath, dependencyChain) {
1116+
const targetInfo = _findMethodInfo(methodPath);
1117+
if (!targetInfo) return null;
1118+
1119+
const targetDoc = _formatMethodDoc(targetInfo);
1120+
1121+
let depsDocs = '';
1122+
if (dependencyChain.length > 0) {
1123+
depsDocs = '\n## 依赖方法(按调用顺序排列,叶子节点在前)\n';
1124+
dependencyChain.forEach((dep, i) => {
1125+
depsDocs += `\n### 依赖 ${i + 1}\n`;
1126+
depsDocs += _formatMethodDoc(dep);
1127+
});
1128+
}
1129+
1130+
const systemPrompt = `你是 EDA(嘉立创EDA/EasyEDA Pro)扩展 API 的测试用例生成器。
1131+
1132+
你的任务是为指定的 API 方法生成**可直接运行的 JavaScript 测试用例代码**。
1133+
1134+
## 规则
1135+
1136+
1. **输出格式**:只输出一段纯 JavaScript 代码,不要包含 markdown 代码块标记(不要 \`\`\`),不要有任何解释文字。
1137+
2. **JSDoc 头部**:代码最上方必须有一个 JSDoc 注释块,格式如下:
1138+
/**
1139+
* <方法全路径>()
1140+
* 方法用例: <方法描述>
1141+
* @parameters
1142+
* <参数名>: <参数描述>
1143+
* ...(如果无参数则写"本方法无输入参数,可直接调用")
1144+
* @returns <返回值描述>
1145+
* @remarks: <备注,如果无则写"无">
1146+
*/
1147+
3. **依赖追溯**:下方提供的依赖方法必须在代码中按顺序先调用,取得返回值后作为目标方法的参数传入。
1148+
4. **变量命名**:每个 API 调用结果用有意义的变量名(如 libraryList、searchResult 等),并在调用后加上 console.log 打印结果。
1149+
5. **所有 API 调用都是异步的**:使用 await 调用,代码在顶层作用域执行(不需要包裹 async 函数)。
1150+
6. **只使用提供的方法**:绝对不要编造不存在的 API。
1151+
7. **参数值**:对于字符串参数使用合理的示例值(如 '' 表示空搜索词),对于可选参数可以使用 undefined。`;
1152+
1153+
const userPrompt = `## 目标方法
1154+
${targetDoc}
1155+
${depsDocs}
1156+
请为 ${methodPath} 生成测试用例代码。`;
1157+
1158+
return { systemPrompt, userPrompt };
1159+
}
1160+
1161+
/**
1162+
* 调用 AI API 生成测试用例并写入编辑器
1163+
*/
1164+
async function generateTestCase(editor, methodPath) {
1165+
let chatConfig;
1166+
try {
1167+
const stored = localStorage.getItem('ai_chat_config');
1168+
chatConfig = stored ? JSON.parse(stored) : null;
1169+
} catch (e) {
1170+
chatConfig = null;
1171+
}
1172+
1173+
if (!chatConfig || !chatConfig.apiKey) {
1174+
if (typeof eda !== 'undefined' && eda.sys_Message) {
1175+
eda.sys_Message.showToastMessage('请先在 AI 配置设置中填写 API Key', 'warn', 3);
1176+
} else {
1177+
alert('请先在 AI 配置设置中填写 API Key');
1178+
}
1179+
return;
1180+
}
1181+
1182+
// 第一阶段:本地依赖追溯(带逐步 toast)
1183+
const dependencyChain = await _traceWithProgress(methodPath);
1184+
1185+
// 第二阶段:构建 prompt 并调用 AI
1186+
const prompts = _buildTestCasePrompt(methodPath, dependencyChain);
1187+
if (!prompts) {
1188+
_toast(`未在方法库中找到 ${methodPath}`, 'error', 2);
1189+
return;
1190+
}
1191+
1192+
_toast('[3/3] 正在生成代码...', 'info', 3);
1193+
1194+
const messages = [
1195+
{ role: 'system', content: prompts.systemPrompt },
1196+
{ role: 'user', content: prompts.userPrompt },
1197+
];
1198+
1199+
try {
1200+
const response = await fetch(`${chatConfig.baseUrl}/chat/completions`, {
1201+
method: 'POST',
1202+
headers: {
1203+
'Content-Type': 'application/json',
1204+
Authorization: `Bearer ${chatConfig.apiKey}`,
1205+
},
1206+
body: JSON.stringify({
1207+
model: chatConfig.model,
1208+
messages: messages,
1209+
temperature: 0.3,
1210+
}),
1211+
});
1212+
1213+
if (!response.ok) {
1214+
const errData = await response.json().catch(() => ({ error: { message: '未知错误' } }));
1215+
throw new Error(errData.error?.message || `HTTP ${response.status}`);
1216+
}
1217+
1218+
const data = await response.json();
1219+
let code = data.choices?.[0]?.message?.content || '';
1220+
1221+
code = code
1222+
.replace(/^```[\w]*\n?/, '')
1223+
.replace(/\n?```\s*$/, '')
1224+
.trim();
1225+
1226+
if (code) {
1227+
editor.setValue(code, -1);
1228+
editor.clearSelection();
1229+
_toast('测试用例已生成', 'success', 2);
1230+
} else {
1231+
throw new Error('AI 返回内容为空');
1232+
}
1233+
} catch (error) {
1234+
console.error('生成测试用例失败:', error);
1235+
_toast(`生成失败: ${error.message}`, 'error', 3);
1236+
}
1237+
}
1238+
9661239
/**
9671240
* 将文本内容保存为 .js 文件并触发浏览器下载
9681241
*

0 commit comments

Comments
 (0)