@@ -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