pytrunner 1.0.0__py3-none-any.whl

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.
pytrunner/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """pytrunner —— Python 项目全覆盖测试框架(命名空间根包)。"""
2
+
3
+ import sys
4
+
5
+ # 兼容仓库「脚本模式」(根目录在 sys.path 上时 `from core.x` / `from tests.x` 等可用)。
6
+ # 打包为命名空间包后顶层不再有这些子包,这里把别名指回本包子模块,
7
+ # 使既有绝对导入在包模式下仍能解析。若已有同名顶层包则不动(setdefault)。
8
+ for _alias, _sub in (
9
+ ("core", "pytrunner.core"),
10
+ ("console", "pytrunner.console"),
11
+ ("gui", "pytrunner.gui"),
12
+ ("tests", "pytrunner.tests"),
13
+ ("probes", "pytrunner.probes"),
14
+ ("scanners", "pytrunner.scanners"),
15
+ ):
16
+ try:
17
+ __import__(_sub)
18
+ sys.modules.setdefault(_alias, sys.modules[_sub])
19
+ except ImportError:
20
+ pass
21
+ del _alias, _sub
@@ -0,0 +1 @@
1
+ # console — 控制台模式界面
@@ -0,0 +1,67 @@
1
+ """
2
+ 控制台测试界面 — 纯终端交互模式。
3
+
4
+ 适用场景:
5
+ - CI/CD 流水线
6
+ - 无 GUI 环境
7
+ - 脚本自动化
8
+ """
9
+
10
+ import sys
11
+ import os
12
+ from typing import List, Callable
13
+
14
+ # 强制 stdout 使用 UTF-8,避免 emoji 等字符在 GBK 下报错
15
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
16
+
17
+ from core.log_manager import LogManager
18
+ from core.test_runner import TestRunner
19
+ from core.error_types import ErrorCode
20
+
21
+
22
+ class ConsoleUI:
23
+ """控制台测试界面"""
24
+
25
+ def __init__(self, log_manager: LogManager):
26
+ self.log = log_manager
27
+ self.runner = TestRunner(log_manager)
28
+
29
+ def print_banner(self):
30
+ """打印启动横幅"""
31
+ banner = r"""
32
+ ╔══════════════════════════════════════════════════╗
33
+ ║ 🧪 测试框架 v1.0 ║
34
+ ║ Console Mode ║
35
+ ╚══════════════════════════════════════════════════╝
36
+ """
37
+ self.log.print_colored(banner, bold=True)
38
+
39
+ def run(self, test_fns: List[Callable], timeout: float = 30.0):
40
+ """执行测试套件"""
41
+ self.print_banner()
42
+ self.runner.timeout = timeout
43
+
44
+ try:
45
+ results = self.runner.run_tests(test_fns)
46
+ self.log.print_summary()
47
+
48
+ # 返回退出码
49
+ has_failure = any(
50
+ r.error_code.category.value in ("失败", "错误")
51
+ for r in results
52
+ )
53
+ if has_failure:
54
+ self.log.print_colored("⚠️ 有测试未通过!", bold=True)
55
+ return 1
56
+ else:
57
+ self.log.print_colored("✅ 全部测试通过!", bold=True)
58
+ return 0
59
+
60
+ except KeyboardInterrupt:
61
+ self.log.print_colored("\n⚠️ 测试被用户中断", bold=True)
62
+ return 2
63
+ except Exception as e:
64
+ self.log.print_colored(f"\n💥 测试框架崩溃: {e}", bold=True)
65
+ import traceback
66
+ traceback.print_exc()
67
+ return 3
@@ -0,0 +1 @@
1
+ # core — 测试框架核心引擎
@@ -0,0 +1,149 @@
1
+ """
2
+ 错误类型定义 — 明确分类测试中可能出现的所有错误。
3
+
4
+ 设计原则:
5
+ 每种错误类型都有唯一的 name/code/emoji,方便日志筛选和 GUI 渲染。
6
+ """
7
+
8
+ from enum import Enum
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime
11
+ from typing import Optional
12
+
13
+
14
+ class ErrorCategory(Enum):
15
+ """错误大类"""
16
+ PASS = "通过"
17
+ FAIL = "失败"
18
+ ERROR = "错误"
19
+ SKIP = "跳过"
20
+
21
+
22
+ class ErrorCode(Enum):
23
+ """错误细类 — 每种错误有唯一标识码"""
24
+
25
+ # ── 通过 ──
26
+ TEST_PASS = ("PASS-000", "测试通过", ErrorCategory.PASS, "✅")
27
+
28
+ # ── 断言失败 ──
29
+ ASSERT_EQUAL = ("FAIL-001", "断言相等失败:期望值与实际值不符", ErrorCategory.FAIL, "❌")
30
+ ASSERT_TRUE = ("FAIL-002", "断言为真失败:表达式求值为 False", ErrorCategory.FAIL, "❌")
31
+ ASSERT_FALSE = ("FAIL-003", "断言为假失败:表达式求值为 True", ErrorCategory.FAIL, "❌")
32
+ ASSERT_IN = ("FAIL-004", "断言包含失败:值不在集合中", ErrorCategory.FAIL, "❌")
33
+ ASSERT_RAISES = ("FAIL-005", "断言异常失败:未抛出预期异常", ErrorCategory.FAIL, "❌")
34
+ UNEXPECTED_RESULT = ("FAIL-006", "意外结果:返回值与预期不符", ErrorCategory.FAIL, "❌")
35
+
36
+ # ── 运行时错误 ──
37
+ RUNTIME_ERROR = ("ERR-001", "运行时错误", ErrorCategory.ERROR, "💥")
38
+ ATTRIBUTE_ERROR = ("ERR-002", "属性错误:对象没有该属性或方法", ErrorCategory.ERROR, "💥")
39
+ TYPE_ERROR = ("ERR-003", "类型错误:操作数类型不匹配", ErrorCategory.ERROR, "💥")
40
+ VALUE_ERROR = ("ERR-004", "值错误:参数值不合法", ErrorCategory.ERROR, "💥")
41
+ KEY_ERROR = ("ERR-005", "键错误:字典中不存在该键", ErrorCategory.ERROR, "💥")
42
+ INDEX_ERROR = ("ERR-006", "索引错误:序列索引越界", ErrorCategory.ERROR, "💥")
43
+ ZERO_DIVISION = ("ERR-007", "零除错误:除数为零", ErrorCategory.ERROR, "💥")
44
+ NAME_ERROR = ("ERR-008", "名称错误:变量未定义", ErrorCategory.ERROR, "💥")
45
+ OS_ERROR = ("ERR-009", "系统错误:操作系统相关异常", ErrorCategory.ERROR, "💥")
46
+ IO_ERROR = ("ERR-010", "IO 错误:文件读写失败", ErrorCategory.ERROR, "💥")
47
+ RECURSION_ERROR = ("ERR-011", "递归错误:递归深度超限", ErrorCategory.ERROR, "💥")
48
+ MEMORY_ERROR = ("ERR-012", "内存错误:内存不足", ErrorCategory.ERROR, "💥")
49
+
50
+ # ── 语法/导入错误 ──
51
+ SYNTAX_ERROR = ("ERR-101", "语法错误:代码无法解析", ErrorCategory.ERROR, "📝")
52
+ IMPORT_ERROR = ("ERR-102", "导入错误:模块不存在或无法加载", ErrorCategory.ERROR, "📝")
53
+ INDENTATION_ERROR = ("ERR-103", "缩进错误:代码缩进不正确", ErrorCategory.ERROR, "📝")
54
+ MODULE_NOT_FOUND = ("ERR-104", "模块未找到:依赖缺失", ErrorCategory.ERROR, "📝")
55
+
56
+ # ── 超时错误 ──
57
+ TIMEOUT_ERROR = ("ERR-201", "超时错误:执行超过时间限制", ErrorCategory.ERROR, "⏱️")
58
+
59
+ # ── 测试框架错误 ──
60
+ SETUP_ERROR = ("ERR-301", "准备阶段错误:setUp 方法异常", ErrorCategory.ERROR, "🔧")
61
+ TEARDOWN_ERROR = ("ERR-302", "清理阶段错误:tearDown 方法异常", ErrorCategory.ERROR, "🔧")
62
+ FIXTURE_ERROR = ("ERR-303", "夹具错误:测试数据准备失败", ErrorCategory.ERROR, "🔧")
63
+
64
+ # ── 跳过 ──
65
+ TEST_SKIP = ("SKIP-000", "测试跳过", ErrorCategory.SKIP, "⏭️")
66
+ SKIP_CONDITION = ("SKIP-001", "条件跳过:前置条件不满足", ErrorCategory.SKIP, "⏭️")
67
+ SKIP_PLATFORM = ("SKIP-002", "平台跳过:当前平台不支持", ErrorCategory.SKIP, "⏭️")
68
+
69
+ # ── 测试探针 ──(快速冒烟检测)
70
+ PROBE_PASS = ("PROBE-PASS", "探针通过:检测点正常", ErrorCategory.PASS, "📍")
71
+ PROBE_FAIL = ("PROBE-FAIL", "探针失败:检测点异常", ErrorCategory.FAIL, "📍")
72
+ PROBE_SKIP = ("PROBE-SKIP", "探针跳过:条件不满足", ErrorCategory.SKIP, "📍")
73
+
74
+ # ── 未知 ──
75
+ UNKNOWN_ERROR = ("ERR-999", "未知错误:无法归类的异常", ErrorCategory.ERROR, "❓")
76
+
77
+ @classmethod
78
+ def from_exception(cls, exc: Exception) -> "ErrorCode":
79
+ """根据异常类型自动匹配 ErrorCode"""
80
+ mapping = {
81
+ AssertionError: cls.ASSERT_EQUAL,
82
+ AttributeError: cls.ATTRIBUTE_ERROR,
83
+ TypeError: cls.TYPE_ERROR,
84
+ ValueError: cls.VALUE_ERROR,
85
+ KeyError: cls.KEY_ERROR,
86
+ IndexError: cls.INDEX_ERROR,
87
+ ZeroDivisionError: cls.ZERO_DIVISION,
88
+ NameError: cls.NAME_ERROR,
89
+ OSError: cls.OS_ERROR,
90
+ IOError: cls.IO_ERROR,
91
+ RecursionError: cls.RECURSION_ERROR,
92
+ MemoryError: cls.MEMORY_ERROR,
93
+ SyntaxError: cls.SYNTAX_ERROR,
94
+ ImportError: cls.IMPORT_ERROR,
95
+ IndentationError: cls.INDENTATION_ERROR,
96
+ ModuleNotFoundError: cls.MODULE_NOT_FOUND,
97
+ TimeoutError: cls.TIMEOUT_ERROR,
98
+ }
99
+ return mapping.get(type(exc), cls.UNKNOWN_ERROR)
100
+
101
+ @property
102
+ def code(self) -> str:
103
+ return self.value[0]
104
+
105
+ @property
106
+ def description(self) -> str:
107
+ return self.value[1]
108
+
109
+ @property
110
+ def category(self) -> ErrorCategory:
111
+ return self.value[2]
112
+
113
+ @property
114
+ def emoji(self) -> str:
115
+ return self.value[3]
116
+
117
+
118
+ @dataclass
119
+ class TestError:
120
+ """单条测试错误记录"""
121
+ error_code: ErrorCode
122
+ message: str
123
+ timestamp: datetime = field(default_factory=datetime.now)
124
+ traceback: Optional[str] = None
125
+ test_name: str = ""
126
+ file_path: str = ""
127
+ line_number: int = 0
128
+
129
+ def brief(self) -> str:
130
+ """一行摘要"""
131
+ loc = f"{self.file_path}:{self.line_number}" if self.file_path else ""
132
+ return f"[{self.error_code.code}] {self.error_code.emoji} {self.message} {loc}"
133
+
134
+ def detail(self) -> str:
135
+ """完整详情"""
136
+ lines = [
137
+ f"{'='*60}",
138
+ f" 错误码 : {self.error_code.code}",
139
+ f" 类型 : {self.error_code.description}",
140
+ f" 时间 : {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}",
141
+ f" 测试 : {self.test_name}",
142
+ ]
143
+ if self.file_path:
144
+ lines.append(f" 位置 : {self.file_path}:{self.line_number}")
145
+ lines.append(f" 消息 : {self.message}")
146
+ if self.traceback:
147
+ lines.append(f" 堆栈 :\n{self.traceback}")
148
+ lines.append(f"{'='*60}")
149
+ return "\n".join(lines)
@@ -0,0 +1,177 @@
1
+ """
2
+ 日志管理器 — 统一管理控制台输出、GUI 回调和文件写入。
3
+
4
+ 功能:
5
+ - 同时输出到多个目标(控制台/GUI 回调/文件)
6
+ - 按错误级别自动着色
7
+ - 自动创建带时间戳的日志文件
8
+ - 线程安全
9
+ """
10
+
11
+ import threading
12
+ from datetime import datetime
13
+ from typing import Optional, Callable, List
14
+ from pathlib import Path
15
+
16
+ from .error_types import ErrorCode, TestError, ErrorCategory
17
+
18
+
19
+ class LogManager:
20
+ """多路日志管理器"""
21
+
22
+ # 控制台颜色(Windows 10+ 支持 ANSI)
23
+ COLORS = {
24
+ ErrorCategory.PASS: "\033[32m",
25
+ ErrorCategory.FAIL: "\033[31m",
26
+ ErrorCategory.ERROR: "\033[35m",
27
+ ErrorCategory.SKIP: "\033[33m",
28
+ "reset": "\033[0m",
29
+ "bold": "\033[1m",
30
+ "dim": "\033[2m",
31
+ "header": "\033[1;36m",
32
+ }
33
+
34
+ def __init__(self, log_dir: str = "logs", use_color: bool = True):
35
+ self.log_dir = Path(log_dir)
36
+ self.log_dir.mkdir(parents=True, exist_ok=True)
37
+
38
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
39
+ self.log_path = self.log_dir / f"test_run_{timestamp}.log"
40
+ self.error_path = self.log_dir / f"test_errors_{timestamp}.log"
41
+
42
+ self._lock = threading.RLock()
43
+ self._gui_callbacks: List[Callable[[str], None]] = []
44
+ self._error_callbacks: List[Callable[[TestError], None]] = []
45
+ self._errors: List[TestError] = []
46
+ self._log_entries: List[str] = []
47
+ self._use_color = use_color
48
+
49
+ self._write_to_file(self.log_path, f"=== 测试日志 === 开始时间: {datetime.now()}\n", mode='w')
50
+ self._write_to_file(self.error_path, f"=== 错误日志 === 开始时间: {datetime.now()}\n\n", mode='w')
51
+
52
+ # ── 回调注册 ──
53
+ def on_log(self, callback: Callable[[str], None]):
54
+ self._gui_callbacks.append(callback)
55
+
56
+ def on_error(self, callback: Callable[[TestError], None]):
57
+ self._error_callbacks.append(callback)
58
+
59
+ # ── 写入 ──
60
+ def _write_to_file(self, path: Path, text: str, mode: str = 'a'):
61
+ try:
62
+ with open(path, mode, encoding='utf-8') as f:
63
+ f.write(text)
64
+ except OSError:
65
+ pass
66
+
67
+ def _notify_gui(self, line: str):
68
+ for cb in self._gui_callbacks:
69
+ try:
70
+ cb(line)
71
+ except Exception:
72
+ pass
73
+
74
+ def _notify_error(self, error: TestError):
75
+ for cb in self._error_callbacks:
76
+ try:
77
+ cb(error)
78
+ except Exception:
79
+ pass
80
+
81
+ # ── 日志方法 ──
82
+ def log(self, msg: str, level: str = "INFO"):
83
+ timestamp = datetime.now().strftime("%H:%M:%S")
84
+ line = f"[{timestamp}] [{level}] {msg}"
85
+ with self._lock:
86
+ self._log_entries.append(line)
87
+ self._write_to_file(self.log_path, line + "\n")
88
+ self._notify_gui(line)
89
+
90
+ def info(self, msg: str):
91
+ self.log(msg, "INFO")
92
+
93
+ def warning(self, msg: str):
94
+ self.log(msg, "WARN")
95
+
96
+ def debug(self, msg: str):
97
+ self.log(msg, "DEBUG")
98
+
99
+ def error(self, test_error: TestError):
100
+ """记录结构化错误"""
101
+ with self._lock:
102
+ self._errors.append(test_error)
103
+ self._write_to_file(self.error_path, test_error.detail() + "\n")
104
+ line = f"[{datetime.now().strftime('%H:%M:%S')}] [ERROR] {test_error.brief()}"
105
+ self._log_entries.append(line)
106
+ self._write_to_file(self.log_path, line + "\n")
107
+ self._notify_gui(line)
108
+ self._notify_error(test_error)
109
+
110
+ def section(self, title: str):
111
+ """打印分隔标题"""
112
+ line = f"\n{'━'*50}\n {title}\n{'━'*50}"
113
+ with self._lock:
114
+ self._log_entries.append(line)
115
+ self._write_to_file(self.log_path, line + "\n")
116
+ self._notify_gui(line)
117
+
118
+ # ── 控制台输出 ──
119
+ def print_colored(self, msg: str, category: Optional[ErrorCategory] = None, bold: bool = False):
120
+ """带颜色的控制台输出"""
121
+ if self._use_color:
122
+ color = self.COLORS.get(category, "")
123
+ style = self.COLORS["bold"] if bold else ""
124
+ reset = self.COLORS["reset"]
125
+ print(f"{style}{color}{msg}{reset}", flush=True)
126
+ else:
127
+ print(msg, flush=True)
128
+
129
+ def print_result(self, error_code: ErrorCode, test_name: str, duration: float = 0):
130
+ """打印单条测试结果"""
131
+ emoji = error_code.emoji
132
+ duration_str = f"({duration:.3f}s)" if duration > 0 else ""
133
+ msg = f" {emoji} {test_name:<40} {error_code.code:<10} {duration_str}"
134
+ self.print_colored(msg, category=error_code.category)
135
+
136
+ # ── 汇总报告 ──
137
+ def print_summary(self):
138
+ """控制台打印汇总报告"""
139
+ total = len(self._errors)
140
+ passed = sum(1 for e in self._errors if e.error_code.category == ErrorCategory.PASS)
141
+ failed = sum(1 for e in self._errors if e.error_code.category == ErrorCategory.FAIL)
142
+ errored = sum(1 for e in self._errors if e.error_code.category == ErrorCategory.ERROR)
143
+ skipped = sum(1 for e in self._errors if e.error_code.category == ErrorCategory.SKIP)
144
+
145
+ self.section("测试汇总")
146
+ print()
147
+ self.print_colored(f" 总计 : {total} 个测试", bold=True)
148
+ self.print_colored(f" 通过 : {passed} ✅", ErrorCategory.PASS)
149
+ self.print_colored(f" 失败 : {failed} ❌", ErrorCategory.FAIL)
150
+ self.print_colored(f" 错误 : {errored} 💥", ErrorCategory.ERROR)
151
+ self.print_colored(f" 跳过 : {skipped} ⏭️", ErrorCategory.SKIP)
152
+ print()
153
+ self.print_colored(f" 日志文件: {self.log_path}", bold=False)
154
+ self.print_colored(f" 错误文件: {self.error_path}", bold=False)
155
+ print()
156
+
157
+ error_list = [e for e in self._errors if e.error_code.category != ErrorCategory.PASS]
158
+ if error_list:
159
+ self.print_colored("失败/错误详情:", ErrorCategory.ERROR, bold=True)
160
+ for e in error_list:
161
+ self.print_colored(f" {e.brief()}", e.error_code.category)
162
+ print()
163
+
164
+ def get_summary(self) -> dict:
165
+ """返回汇总数据(GUI 用)"""
166
+ total = len(self._errors)
167
+ return {
168
+ "total": total,
169
+ "passed": sum(1 for e in self._errors if e.error_code.category == ErrorCategory.PASS),
170
+ "failed": sum(1 for e in self._errors if e.error_code.category == ErrorCategory.FAIL),
171
+ "errored": sum(1 for e in self._errors if e.error_code.category == ErrorCategory.ERROR),
172
+ "skipped": sum(1 for e in self._errors if e.error_code.category == ErrorCategory.SKIP),
173
+ "log_path": str(self.log_path),
174
+ "error_path": str(self.error_path),
175
+ "errors": self._errors,
176
+ "entries": self._log_entries,
177
+ }
@@ -0,0 +1,200 @@
1
+ """
2
+ 测试探针系统 — 通过插入轻量级探针进行冒烟测试。
3
+
4
+ 概念:
5
+ 测试探针(Probe)是在关键路径上插入的轻量检测点。
6
+ 不改变被测代码逻辑,仅在关键节点做快速验证。
7
+ 适合 CI/CD 冒烟测试、部署后健康检查、快速回归验证。
8
+
9
+ 设计原则:
10
+ - 探针是无副作用的只读检查
11
+ - 批量触发,快速扫过所有探针点
12
+ - 失败探针记录详细上下文
13
+ """
14
+
15
+ import time
16
+ import traceback
17
+ from typing import Any, Callable, Dict, List, Optional, Set
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime
20
+
21
+ from .error_types import ErrorCode, TestError, ErrorCategory
22
+ from .log_manager import LogManager
23
+
24
+
25
+ @dataclass
26
+ class Probe:
27
+ """单个测试探针
28
+
29
+ Args:
30
+ name: 探针名称(唯一标识)
31
+ check: 检查函数,返回 (passed: bool, detail: str)
32
+ category: 探针分类(如 "启动", "数据库", "API", "UI")
33
+ priority: 优先级 1-5,1 最高(数字越小越优先扫描)
34
+ tags: 标签集合,用于按标签筛选探针
35
+ timeout: 超时秒数
36
+ """
37
+ name: str
38
+ check: Callable[[], tuple[bool, str]]
39
+ category: str = "通用"
40
+ priority: int = 3
41
+ tags: Set[str] = field(default_factory=set)
42
+ timeout: float = 3.0
43
+
44
+ def run(self) -> TestError:
45
+ """执行探针检测,返回 TestError"""
46
+ start = time.perf_counter()
47
+ try:
48
+ passed, detail = self.check()
49
+ except Exception as e:
50
+ duration = time.perf_counter() - start
51
+ return TestError(
52
+ error_code=ErrorCode.PROBE_FAIL,
53
+ message=f"{type(e).__name__}: {e}",
54
+ test_name=f"[探针] {self.name}",
55
+ traceback=traceback.format_exc(),
56
+ )
57
+
58
+ duration = time.perf_counter() - start
59
+ if passed:
60
+ return TestError(
61
+ error_code=ErrorCode.PROBE_PASS,
62
+ message=f"✅ {detail} ({duration:.3f}s)",
63
+ test_name=f"[探针] {self.name}",
64
+ )
65
+ else:
66
+ return TestError(
67
+ error_code=ErrorCode.PROBE_FAIL,
68
+ message=f"❌ {detail} ({duration:.3f}s)",
69
+ test_name=f"[探针] {self.name}",
70
+ )
71
+
72
+
73
+ class ProbeManager:
74
+ """探针管理器 — 注册、筛选、批量执行"""
75
+
76
+ def __init__(self, log_manager: LogManager):
77
+ self.log = log_manager
78
+ self._probes: List[Probe] = []
79
+
80
+ # ── 注册 ──
81
+ def register(self, probe: Probe):
82
+ """注册一个探针"""
83
+ self._probes.append(probe)
84
+
85
+ def register_batch(self, probes: List[Probe]):
86
+ """批量注册探针"""
87
+ self._probes.extend(probes)
88
+
89
+ @property
90
+ def count(self) -> int:
91
+ return len(self._probes)
92
+
93
+ # ── 筛选 ──
94
+ def filter(self, *, category: str = None, tags: Set[str] = None,
95
+ max_priority: int = None) -> List[Probe]:
96
+ """按条件筛选探针
97
+
98
+ Args:
99
+ category: 按分类筛选
100
+ tags: 按标签筛选(包含任一标签即匹配)
101
+ max_priority: 最大优先级(数字),只返回优先级 <= 此值的探针
102
+ """
103
+ result = self._probes
104
+ if category:
105
+ result = [p for p in result if p.category == category]
106
+ if tags:
107
+ result = [p for p in result if p.tags & tags]
108
+ if max_priority is not None:
109
+ result = [p for p in result if p.priority <= max_priority]
110
+ return result
111
+
112
+ def smoke_probes(self) -> List[Probe]:
113
+ """获取冒烟探针:仅 priority=1 的关键路径探针"""
114
+ return self.filter(max_priority=1)
115
+
116
+ def quick_probes(self) -> List[Probe]:
117
+ """获取快速探针:priority <= 2"""
118
+ return self.filter(max_priority=2)
119
+
120
+ # ── 执行 ──
121
+ def run(self, probes: List[Probe] = None) -> List[TestError]:
122
+ """批量执行探针
123
+
124
+ Args:
125
+ probes: 要执行的探针列表。None 表示全部。
126
+
127
+ Ctrl+C 处理:
128
+ 中断后保留已完成的结果,打印部分汇总后退出。
129
+ """
130
+ targets = probes if probes is not None else self._probes
131
+ results = []
132
+ total = len(targets)
133
+ interrupted = False
134
+
135
+ self.log.section(f"📍 探针扫描 — {total} 个检测点")
136
+
137
+ try:
138
+ for i, probe in enumerate(targets, 1):
139
+ tag_str = f"[{probe.category}]" if probe.category else ""
140
+ self.log.info(f"[{i}/{total}] {tag_str} 探针: {probe.name}")
141
+
142
+ try:
143
+ result = probe.run()
144
+ except KeyboardInterrupt:
145
+ raise # 重新抛出给外层 catch
146
+ self.log.print_result(result.error_code, result.test_name)
147
+ self.log.error(result)
148
+ results.append(result)
149
+
150
+ except KeyboardInterrupt:
151
+ interrupted = True
152
+ remaining = total - len(results)
153
+ self.log.section("⚠️ 用户中断 (Ctrl+C)")
154
+ self.log.warning(f"已执行 {len(results)}/{total} 个探针,剩余 {remaining} 个未执行")
155
+ self.log.print_colored(f"\n⚠️ 用户中断 — 已扫描 {len(results)}/{total} 个探针", bold=True)
156
+
157
+ passed = sum(1 for r in results if r.error_code.category == ErrorCategory.PASS)
158
+ failed = len(results) - passed
159
+
160
+ if interrupted:
161
+ self.log.info(f"中断前结果: {passed}/{len(results)} 通过, {failed} 失败")
162
+ else:
163
+ self.log.info(f"探针扫描完成: {passed}/{total} 通过, {failed} 失败")
164
+
165
+ return results
166
+
167
+ def run_smoke(self) -> List[TestError]:
168
+ """执行冒烟测试(仅 priority=1 的关键探针)"""
169
+ probes = self.smoke_probes()
170
+ self.log.section("🔥 冒烟测试 — 关键路径检测")
171
+ return self.run(probes)
172
+
173
+ def run_category(self, category: str) -> List[TestError]:
174
+ """按分类执行探针"""
175
+ probes = self.filter(category=category)
176
+ self.log.section(f"📍 分类扫描: {category}")
177
+ return self.run(probes)
178
+
179
+ # ── 快捷探针工厂 ──
180
+ @staticmethod
181
+ def simple(name: str, condition: bool, detail: str = "",
182
+ category: str = "通用", priority: int = 3,
183
+ tags: Set[str] = None) -> Probe:
184
+ """快速创建条件探针
185
+
186
+ Args:
187
+ name: 探针名称
188
+ condition: 检查条件(True = 通过)
189
+ detail: 详情描述
190
+ category: 分类
191
+ priority: 优先级
192
+ tags: 标签
193
+ """
194
+ return Probe(
195
+ name=name,
196
+ check=lambda c=condition, d=detail: (c, d if d else f"条件{'满足' if c else '不满足'}"),
197
+ category=category,
198
+ priority=priority,
199
+ tags=tags or set(),
200
+ )