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 +21 -0
- pytrunner/console/__init__.py +1 -0
- pytrunner/console/console_ui.py +67 -0
- pytrunner/core/__init__.py +1 -0
- pytrunner/core/error_types.py +149 -0
- pytrunner/core/log_manager.py +177 -0
- pytrunner/core/test_probe.py +200 -0
- pytrunner/core/test_runner.py +173 -0
- pytrunner/gui/__init__.py +1 -0
- pytrunner/gui/canvas_view.py +664 -0
- pytrunner/gui/config.py +60 -0
- pytrunner/main.py +852 -0
- pytrunner/probes/__init__.py +0 -0
- pytrunner/probes/draw_probes.py +612 -0
- pytrunner/probes/ok_probes.py +172 -0
- pytrunner/probes/probe_cases.py +255 -0
- pytrunner/scanners/__init__.py +0 -0
- pytrunner/scanners/allin_scanner.py +478 -0
- pytrunner/scanners/architecture_detector.py +968 -0
- pytrunner/scanners/dynamic_tester.py +620 -0
- pytrunner/tests/__init__.py +0 -0
- pytrunner/tests/test_allin_scanner.py +84 -0
- pytrunner/tests/test_architecture_detector.py +52 -0
- pytrunner/tests/test_cases.py +226 -0
- pytrunner/tests/test_merged_gui_dynamic.py +170 -0
- pytrunner/tests/test_merged_gui_dynamic_v2.py +267 -0
- pytrunner/tests/test_merged_gui_static.py +278 -0
- pytrunner-1.0.0.dist-info/METADATA +96 -0
- pytrunner-1.0.0.dist-info/RECORD +33 -0
- pytrunner-1.0.0.dist-info/WHEEL +5 -0
- pytrunner-1.0.0.dist-info/entry_points.txt +2 -0
- pytrunner-1.0.0.dist-info/licenses/LICENSE +21 -0
- pytrunner-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
)
|