diff --git a/demo.xlsm b/demo.xlsm index bc17e32..687bd97 100644 Binary files a/demo.xlsm and b/demo.xlsm differ diff --git a/vba_test_runner.py b/vba_test_runner.py index aee0ec1..5354367 100644 --- a/vba_test_runner.py +++ b/vba_test_runner.py @@ -19,6 +19,8 @@ class TestResult: error_description: str = "" error_line: int = 0 source_code: str = "" + error_module: str = "" # 新增:错误发生的模块 + call_chain: str = "" # 新增:完整调用链 class CodeWeaver: @@ -27,6 +29,41 @@ class CodeWeaver: def __init__(self): self.line_counter = 10 # 行号标签从 10 开始,步长 10 + def parse_modules(self, vba_project) -> Dict[str, Dict]: + """ + 解析VBA项目中的所有模块 + + 返回: { + 模块名: { + 'type': 'StdModule' | 'ClassModule', + 'code': str, + 'procedures': {过程名: {...过程信息}}, + 'component': VBComponent + } + } + """ + modules = {} + for component in vba_project.VBComponents: + # 只处理标准模块和类模块 + if component.Type in [1, 2]: # 1=StdModule, 2=ClassModule + module_name = component.Name + code = self._get_component_code(component) + procedures = self.parse_procedures(code) + + modules[module_name] = { + 'type': 'StdModule' if component.Type == 1 else 'ClassModule', + 'code': code, + 'procedures': procedures, + 'component': component + } + return modules + + def _get_component_code(self, component) -> str: + """从VBComponent获取代码""" + code_module = component.CodeModule + line_count = code_module.CountOfLines + return code_module.Lines(1, line_count) if line_count > 0 else "" + def parse_procedures(self, code: str) -> Dict[str, Dict]: """ 解析 VBA 代码中的所有 Sub/Function @@ -75,6 +112,84 @@ class CodeWeaver: return procedures + def weave_procedure_with_callstack(self, code: str, proc_name: str, module_name: str) -> Tuple[str, Dict[int, str]]: + """ + 为指定过程注入调用栈管理、行号和错误处理(阶段1增强版) + + 返回: (编织后的代码, {行号: 源代码}) + """ + procedures = self.parse_procedures(code) + + if proc_name not in procedures: + raise ValueError(f"Procedure '{proc_name}' not found in code") + + proc_info = procedures[proc_name] + lines = code.split('\n') + + # 提取过程代码(包括定义行和结束行) + proc_lines = lines[proc_info['start_line']:proc_info['end_line'] + 1] + + # 注入行号 + woven_lines, source_map = self._inject_line_numbers(proc_lines) + + # 注入错误处理和调用栈管理 + woven_lines = self._inject_error_handler_with_callstack(woven_lines, proc_name, module_name) + + # 替换原代码中的过程 + result_lines = ( + lines[:proc_info['start_line']] + + woven_lines + + lines[proc_info['end_line'] + 1:] + ) + + return '\n'.join(result_lines), source_map + + def weave_module_all_procedures(self, code: str, module_name: str) -> Tuple[str, Dict[int, str]]: + """ + 为单个模块中的所有过程注入调用栈管理和错误处理 + + 这对于调用链追踪至关重要:当测试一个过程时, + 该过程调用的其他过程也需要被编织,以便记录 LogEntry/LogExit + + 返回: (编织后的代码, {行号: 源代码}) + """ + procedures = self.parse_procedures(code) + woven_code = code + source_map = {} + + # 为模块中的每个过程注入代码 + for proc_name in procedures.keys(): + woven_code, proc_source_map = self.weave_procedure_with_callstack( + woven_code, proc_name, module_name + ) + source_map.update(proc_source_map) + + return woven_code, source_map + + def weave_all_modules(self, modules: Dict[str, Dict]) -> Dict[str, str]: + """ + 为所有模块中的所有过程注入调用栈管理和错误处理 + + 返回: {模块名: 编织后的代码} + """ + woven_modules = {} + + for module_name, module_info in modules.items(): + original_code = module_info['code'] + woven_code = original_code + source_map = {} + + # 为每个过程注入代码 + for proc_name in module_info['procedures'].keys(): + woven_code, proc_source_map = self.weave_procedure_with_callstack( + woven_code, proc_name, module_name + ) + source_map.update(proc_source_map) + + woven_modules[module_name] = woven_code + + return woven_modules + def weave_procedure(self, code: str, proc_name: str) -> Tuple[str, Dict[int, str]]: """ 为指定过程注入行号和错误处理 @@ -161,6 +276,87 @@ class CodeWeaver: return result_lines, source_map + def _inject_error_handler_with_callstack(self, lines: List[str], proc_name: str, module_name: str) -> List[str]: + """ + 在过程开头添加: On Error GoTo Auto_Err_Handler + 注入调用栈管理: LogEntry/LogExit + 删除任何现有的 On Error 语句 + 在过程结束前添加错误处理块 + """ + result_lines = [] + on_error_pattern = re.compile(r'^\s*On\s+Error.*$', re.IGNORECASE) + proc_end_pattern = re.compile(r'^\s*End\s+(Sub|Function)', re.IGNORECASE) + exit_pattern = re.compile(r'^\s*(Exit\s+(Sub|Function))', re.IGNORECASE) + + # 确定过程类型 + proc_type = "Sub" + for line in lines: + if re.match(r'^\s*(Public|Private|Friend)?\s*Function', line, re.IGNORECASE): + proc_type = "Function" + break + + # 在过程定义后插入 LogEntry 和 On Error 语句 + inserted_log_entry = False + inserted_error_handler = False + error_handler_inserted = False + + for i, line in enumerate(lines): + # 跳过现有的 On Error 语句 + if on_error_pattern.match(line): + continue + + # 检查是否是过程结束 + is_end = proc_end_pattern.match(line) + + if is_end and not error_handler_inserted: + # 在 End Sub/Function 之前插入错误处理块 + result_lines.append(f"") + result_lines.append(f" Call TestLogger.LogExit()") + result_lines.append(f" Call TestLogger.LogSuccess()") + if proc_type == "Sub": + result_lines.append(f" Exit Sub") + else: + result_lines.append(f" Exit Function") + result_lines.append(f"") + result_lines.append(f"Auto_Err_Handler_{proc_name}:") + result_lines.append(f" Call TestLogger.LogError(\"{proc_name}\", \"{module_name}\", Err.Number, Err.Description, Erl)") + result_lines.append(f" Call TestLogger.LogExit()") + result_lines.append(line) # 添加 End Sub/Function + error_handler_inserted = True + elif is_end: + # 跳过后续的 End 语句(不应该有) + pass + elif exit_pattern.match(line): + # 跳过现有的 Exit 语句,替换为 LogExit + Exit + result_lines.append(f" Call TestLogger.LogExit()") + result_lines.append(line) + else: + # 在第一个非声明、非空行后插入 LogEntry 和 On Error + if not inserted_log_entry and not re.match(r'^\s*(\'|Dim|Private|Public|Const|$)', line, re.IGNORECASE): + if not re.match(r'^\s*(Public|Private|Friend)?\s*(Sub|Function)', line, re.IGNORECASE): + result_lines.append(f" Call TestLogger.LogEntry(\"{proc_name}\", \"{module_name}\")") + result_lines.append(f" On Error GoTo Auto_Err_Handler_{proc_name}") + inserted_log_entry = True + inserted_error_handler = True + + result_lines.append(line) + + # 如果没有找到 End,追加到末尾 + if not error_handler_inserted: + result_lines.append(f"") + result_lines.append(f" Call TestLogger.LogExit()") + result_lines.append(f" Call TestLogger.LogSuccess()") + if proc_type == "Sub": + result_lines.append(f" Exit Sub") + else: + result_lines.append(f" Exit Function") + result_lines.append(f"") + result_lines.append(f"Auto_Err_Handler_{proc_name}:") + result_lines.append(f" Call TestLogger.LogError(\"{proc_name}\", \"{module_name}\", Err.Number, Err.Description, Erl)") + result_lines.append(f" Call TestLogger.LogExit()") + + return result_lines + def _inject_error_handler(self, lines: List[str], proc_name: str) -> List[str]: """ 在过程开头添加: On Error GoTo Auto_Err_Handler @@ -235,58 +431,130 @@ class CodeWeaver: class LoggerInjector: - """日志模块注入器 - 向 Excel 项目注入 _TestLogger 辅助模块""" + """日志模块注入器 - 向 Excel 项目注入 TestLogger 辅助模块和 CallStack 类模块""" + + CALLSTACK_CLASS_CODE = """Option Explicit + +Private m_Stack As Collection + +Private Sub Class_Initialize() + Set m_Stack = New Collection +End Sub + +Public Sub Push(ByVal procName As String, ByVal moduleName As String) + m_Stack.Add moduleName & "." & procName +End Sub + +Public Sub Pop() + If m_Stack.Count > 0 Then m_Stack.Remove m_Stack.Count +End Sub + +Public Function GetCallChain() As String + Dim i As Integer + Dim parts() As String + ReDim parts(1 To m_Stack.Count) + + For i = 1 To m_Stack.Count + parts(i) = m_Stack(i) + Next i + + GetCallChain = Join(parts, " -> ") +End Function + +Public Sub Clear() + Set m_Stack = New Collection +End Sub +""" LOGGER_MODULE_CODE = """Option Explicit ' Module-level variables to store test result Private m_TestStatus As String +Private m_ErrModule As String Private m_ErrProcedure As String Private m_ErrNumber As Long Private m_ErrDescription As String Private m_ErrLine As Long +Private m_CallChain As String +Private m_CallStack As CallStack -Sub LogError(ByVal procName As String, ByVal errNum As Long, _ - ByVal errDesc As String, ByVal errLine As Long) +Sub LogEntry(ByVal procName As String, ByVal moduleName As String) + If m_CallStack Is Nothing Then Set m_CallStack = New CallStack + m_CallStack.Push procName, moduleName +End Sub + +Sub LogExit() + If Not m_CallStack Is Nothing Then m_CallStack.Pop +End Sub + +Sub LogError(ByVal procName As String, ByVal moduleName As String, _ + ByVal errNum As Long, ByVal errDesc As String, ByVal errLine As Long) m_TestStatus = "ERROR" + m_ErrModule = moduleName m_ErrProcedure = procName m_ErrNumber = errNum m_ErrDescription = errDesc m_ErrLine = errLine + If Not m_CallStack Is Nothing Then + m_CallChain = m_CallStack.GetCallChain() + Else + m_CallChain = "" + End If End Sub Sub LogSuccess() - m_TestStatus = "SUCCESS" - m_ErrProcedure = "" - m_ErrNumber = 0 - m_ErrDescription = "" - m_ErrLine = 0 + ' 只有在没有错误时才设置为成功 + ' 这确保子过程的错误不会被父过程覆盖 + If m_TestStatus <> "ERROR" Then + m_TestStatus = "SUCCESS" + End If + ' 只有在没有错误时才清空调用栈 + If m_TestStatus <> "ERROR" Then + If Not m_CallStack Is Nothing Then m_CallStack.Clear + End If End Sub Function GetResult() As String If m_TestStatus = "SUCCESS" Then GetResult = "SUCCESS" Else - GetResult = "ERROR|" & m_ErrProcedure & "|" & m_ErrLine & "|" & _ - m_ErrNumber & "|" & m_ErrDescription + GetResult = "ERROR|" & m_ErrModule & "." & m_ErrProcedure & "|" & _ + m_ErrLine & "|" & m_ErrNumber & "|" & m_ErrDescription & "|" & _ + m_CallChain End If End Function + +Sub Initialize() + m_TestStatus = "SUCCESS" + If Not m_CallStack Is Nothing Then m_CallStack.Clear +End Sub """ def inject_or_replace(self, wb, module_name: str = "TestLogger") -> None: """ - 如果模块存在则删除,然后注入新的 Logger 模块 + 如果模块存在则删除,然后注入新的 Logger 模块和 CallStack 类模块 """ # 获取 VBA 项目 vba_project = wb.api.VBProject - # 检查模块是否存在,存在则删除 + # 检查并删除旧的 CallStack 类模块 + for component in vba_project.VBComponents: + if component.Name == "CallStack": + vba_project.VBComponents.Remove(component) + break + + # 注入 CallStack 类模块 + callstack_module = vba_project.VBComponents.Add(2) # 2 = vbext_ct_ClassModule + callstack_module.Name = "CallStack" + callstack_module.CodeModule.AddFromString(self.CALLSTACK_CLASS_CODE) + + # 检查并删除旧的 Logger 模块 for component in vba_project.VBComponents: if component.Name == module_name: vba_project.VBComponents.Remove(component) break - # 注入新模块 + # 注入新 Logger 模块 new_module = vba_project.VBComponents.Add(1) # 1 = vbext_ct_StdModule new_module.Name = module_name new_module.CodeModule.AddFromString(self.LOGGER_MODULE_CODE) @@ -331,9 +599,10 @@ class TestRunner: # 2. 读取原始 VBA 代码 original_code = self._get_vba_code(module_name) - # 3. 编织代码 - woven_code, self.source_map = self.code_weaver.weave_procedure( - original_code, proc_name + # 3. 编织整个模块的所有过程(以支持调用链追踪) + # 注意:为了追踪调用链,我们需要编织模块中的所有过程 + woven_code, self.source_map = self.code_weaver.weave_module_all_procedures( + original_code, module_name ) # 4. 注入 Logger 模块 @@ -357,7 +626,9 @@ class TestRunner: error_number=0, error_description=f"Test execution failed: {str(e)}", error_line=0, - source_code="" + source_code="", + error_module="", + call_chain="" ) finally: # 8. 关闭工作簿(不保存) @@ -400,7 +671,7 @@ class TestRunner: pass def _get_test_result(self, proc_name: str) -> TestResult: - """从 Logger 模块获取测试结果""" + """从 Logger 模块获取测试结果(增强版,支持调用链)""" try: result_str = self.app.api.Run("TestLogger.GetResult") @@ -410,24 +681,33 @@ class TestRunner: success=True ) else: - # 解析错误信息: ERROR|ProcedureName|Line|Number|Description + # 解析错误信息: ERROR|Module.Proc|Line|Number|Desc|CallChain parts = result_str.split('|') - if len(parts) >= 5: - error_proc_name = parts[1] + if len(parts) >= 6: + error_module_proc = parts[1] error_line = int(parts[2]) error_number = int(parts[3]) error_desc = parts[4] + call_chain = parts[5] + + # 分离模块和过程名 + error_module = "" + error_proc = error_module_proc + if '.' in error_module_proc: + error_module, error_proc = error_module_proc.split('.', 1) # 从 Source Map 获取源代码 source_code = self.source_map.get(error_line, "Source code not found") return TestResult( - procedure_name=error_proc_name, + procedure_name=error_proc, success=False, error_number=error_number, error_description=error_desc, error_line=error_line, - source_code=source_code + source_code=source_code, + error_module=error_module, + call_chain=call_chain ) except Exception as e: return TestResult( @@ -436,7 +716,9 @@ class TestRunner: error_number=0, error_description=f"Failed to get test result: {str(e)}", error_line=0, - source_code="" + source_code="", + error_module="", + call_chain="" ) def _cleanup(self): @@ -449,18 +731,163 @@ class TestRunner: except: pass + # ==================== Phase 2: 全项目测试方法 ==================== + + def discover_all_tests(self) -> List[Dict]: + """ + 发现所有可测试的过程 + + 返回: [ + { + 'module': str, + 'procedure': str, + 'type': 'Sub' | 'Function', + 'is_entry_point': bool + }, + ... + ] + """ + # 打开Excel(如果未打开) + if not self.wb: + self.app = xw.App(visible=self.visible) + self.wb = self.app.books.open(self.file_path) + + vba_project = self.wb.api.VBProject + modules = self.code_weaver.parse_modules(vba_project) + + all_tests = [] + for module_name, module_info in modules.items(): + for proc_name, proc_info in module_info['procedures'].items(): + # 判断是否是入口点 + is_entry_point = self._is_entry_point(module_name, proc_name) + + all_tests.append({ + 'module': module_name, + 'procedure': proc_name, + 'type': proc_info['type'], + 'is_entry_point': is_entry_point + }) + + return all_tests + + def _is_entry_point(self, module_name: str, proc_name: str) -> bool: + """ + 判断过程是否可以作为测试入口点 + + 规则: + 1. 过程名不以"Worksheet_"、"Workbook_"开头(排除事件过程) + 2. 过程名不以"Class_"开头(排除类模块内部方法,可选) + """ + # 排除事件过程 + if proc_name.startswith(('Worksheet_', 'Workbook_', 'Document_', 'Class_')): + return False + + # 排除Logger相关过程 + if proc_name.startswith(('LogEntry', 'LogExit', 'LogError', 'LogSuccess', 'GetResult', 'Initialize')): + return False + + # 简化实现:假设所有非事件过程都是入口点 + return True + + def run_all_tests(self) -> List[TestResult]: + """ + 执行全项目测试 + + Returns: + 所有测试结果的列表 + """ + # 发现所有测试 + all_tests = self.discover_all_tests() + entry_tests = [t for t in all_tests if t['is_entry_point']] + + print(f"发现 {len(entry_tests)} 个可测试的入口点") + print(f"总过程数: {len(all_tests)}") + + # 打开Excel(如果未打开) + if not self.wb: + self.app = xw.App(visible=self.visible) + self.wb = self.app.books.open(self.file_path) + + # 一次性编织所有模块 + self._weave_all_modules_inplace() + + # 执行所有测试 + results = [] + for i, test in enumerate(entry_tests, 1): + print(f"\n[{i}/{len(entry_tests)}] 测试 {test['module']}.{test['procedure']}") + + try: + result = self._run_single_test(test['module'], test['procedure']) + results.append(result) + print_test_result(result) + except Exception as e: + print(f" [ERROR] 测试执行失败: {str(e)}") + results.append(TestResult( + procedure_name=test['procedure'], + success=False, + error_description=f"Test execution failed: {str(e)}" + )) + + return results + + def _weave_all_modules_inplace(self): + """在Excel中直接编织所有模块""" + vba_project = self.wb.api.VBProject + modules = self.code_weaver.parse_modules(vba_project) + + # 注入增强的Logger模块和CallStack类 + self.logger_injector.inject_or_replace(self.wb) + + # 编织所有模块 + woven_modules = self.code_weaver.weave_all_modules(modules) + + # 热替换所有模块代码 + for module_name, woven_code in woven_modules.items(): + try: + self._replace_module_code(module_name, woven_code) + except Exception as e: + print(f" 警告: 无法编织模块 {module_name}: {str(e)}") + + def _run_single_test(self, module_name: str, proc_name: str) -> TestResult: + """执行单个测试(用于批量测试)""" + # 初始化Logger + try: + self.app.api.Run("TestLogger.Initialize") + except: + pass + + # 执行宏 + try: + self._execute_macro(f"{module_name}.{proc_name}") + except: + pass # 错误会被VBA错误处理捕获 + + # 获取结果 + return self._get_test_result(proc_name) + def print_test_result(result: TestResult, index: int = None, total: int = None): - """打印单个测试结果""" + """打印单个测试结果(增强版,支持调用链)""" prefix = f"[{index}/{total}] " if index and total else "" if result.success: - print(f"{prefix}[PASS] {result.procedure_name} - Test Passed") + print(f"{prefix}[PASS] {result.procedure_name}") else: - print(f"{prefix}[FAIL] {result.procedure_name} - Test Failed") - print(f" Error Description: {result.error_description}") - print(f" Error Line: {result.error_line}") - print(f" Source Code: {result.source_code}") + print(f"{prefix}[FAIL] {result.procedure_name}") + print(f" Error: {result.error_description}") + + # 显示位置信息 + if result.error_module: + print(f" Location: {result.error_module}.{result.procedure_name}:{result.error_line}") + else: + print(f" Location: {result.procedure_name}:{result.error_line}") + + # 显示调用链 + if result.call_chain: + print(f" Call Chain: {result.call_chain}") + + # 显示源代码 + print(f" Source: {result.source_code}") def print_summary(results: List[TestResult]): @@ -475,27 +902,57 @@ def print_summary(results: List[TestResult]): def main(): - """主入口函数""" - if len(sys.argv) < 3: - print("Usage: python vba_test_runner.py [procedure2] ...") - print("Example: python vba_test_runner.py demo.xlsm Module1 TestErrorProcedure") + """主入口函数(支持两种模式:全项目测试和指定过程测试)""" + if len(sys.argv) < 2: + print("用法1: python vba_test_runner.py --all") + print("用法2: python vba_test_runner.py [procedure2] ...") + print("示例1: python vba_test_runner.py demo.xlsm --all") + print("示例2: python vba_test_runner.py demo.xlsm Module1 TestErrorProcedure") sys.exit(1) excel_file = sys.argv[1] - module_name = sys.argv[2] - procedures = sys.argv[3:] - print(f"===== VBA Batch Test Results =====\n") - - results = [] - - for i, proc_name in enumerate(procedures, 1): + # 模式1:全项目测试 + if len(sys.argv) == 2 and sys.argv[1] == "--all": + # 这种情况应该是: python vba_test_runner.py --all + # 但实际上应该是: python vba_test_runner.py --all + print("错误: 缺少Excel文件路径") + print("正确用法: python vba_test_runner.py --all") + sys.exit(1) + elif len(sys.argv) >= 3 and sys.argv[2] == "--all": + print("===== VBA 全项目自动化测试 =====\n") runner = TestRunner(excel_file, visible=False) - result = runner.run_test(module_name, proc_name) - results.append(result) - print_test_result(result, i, len(procedures)) + try: + results = runner.run_all_tests() + print_summary(results) + finally: + runner._cleanup() - print_summary(results) + # 模式2:指定过程测试(保持向后兼容) + elif len(sys.argv) >= 3: + module_name = sys.argv[2] + procedures = sys.argv[3:] + + if not procedures: + print("错误: 必须指定至少一个过程名") + sys.exit(1) + + print(f"===== VBA 批量测试结果 =====\n") + + results = [] + for i, proc_name in enumerate(procedures, 1): + runner = TestRunner(excel_file, visible=False) + result = runner.run_test(module_name, proc_name) + results.append(result) + print_test_result(result, i, len(procedures)) + + print_summary(results) + + else: + print("错误: 无效的参数") + print("用法1: python vba_test_runner.py --all") + print("用法2: python vba_test_runner.py [procedure2] ...") + sys.exit(1) if __name__ == "__main__":