first commit
This commit is contained in:
265
.gitignore
vendored
Normal file
265
.gitignore
vendored
Normal file
@@ -0,0 +1,265 @@
|
||||
# Python Git Ignore
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be added to the global gitignore or merged into this project gitignore. For a PyCharm
|
||||
# project, it is recommended to use the following settings:
|
||||
# https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# Uncomment the following lines if you want to ignore PyCharm files:
|
||||
.idea/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# Sublime Text
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Vim
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Emacs
|
||||
*~
|
||||
\#*\#
|
||||
/.emacs.desktop
|
||||
/.emacs.desktop.lock
|
||||
*.elc
|
||||
auto-save-list
|
||||
tramp
|
||||
.\#*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Icon
|
||||
._*
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
*.tmp
|
||||
*.temp
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
*.lnk
|
||||
|
||||
# Linux
|
||||
*~
|
||||
.fuse_hidden*
|
||||
.directory
|
||||
.Trash-*
|
||||
.nfs*
|
||||
|
||||
# Project-specific ignores
|
||||
# Temporary files
|
||||
temp/
|
||||
tmp/
|
||||
*.tmp
|
||||
|
||||
# Log files
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Configuration files with sensitive data
|
||||
config_local.py
|
||||
secrets.py
|
||||
.secrets
|
||||
|
||||
# Test artifacts
|
||||
test_results/
|
||||
test_reports/
|
||||
|
||||
# Screenshots and media (if not needed in version control)
|
||||
# Uncomment if you don't want to track these files:
|
||||
# screenshots/
|
||||
# reports/
|
||||
# visual_comparisons/
|
||||
|
||||
# Appium specific
|
||||
appium.log
|
||||
node_modules/
|
||||
|
||||
# Device specific files
|
||||
device_info.json
|
||||
|
||||
396
README.md
Normal file
396
README.md
Normal file
@@ -0,0 +1,396 @@
|
||||
# 🚀 企业级UI测试工具
|
||||
|
||||
基于Appium的Android应用UI测试工具,采用模块化架构设计,支持实时截图、XML布局分析、视觉比对、性能监控、智能报告生成等企业级功能。
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
### 🎯 主要功能
|
||||
- **🔄 零配置启动**: 自动创建目录结构,开箱即用
|
||||
- **📸 实时截图**: 快速捕获当前屏幕状态,支持批量操作
|
||||
- **🔍 智能分析**: XML布局深度解析,自动识别UI问题
|
||||
- **👁️ 视觉验证**: 纯图像像素级对比,检查UI外观效果
|
||||
- **📐 布局比对**: XML-SVG结构比对,验证布局逻辑一致性
|
||||
- **⚡ 性能优化**: 实时监控操作响应时间和设备状态
|
||||
- **📊 详细报告**: 生成JSON/HTML格式的专业测试报告
|
||||
|
||||
### 🏗️ 模块化架构
|
||||
|
||||
项目采用清晰的模块化设计,便于维护和扩展:
|
||||
|
||||
```
|
||||
📁 项目结构
|
||||
├── 📄 ui_test.py # 主程序入口和交互界面
|
||||
├── ⚙️ config.py # 统一配置管理
|
||||
├── 🔍 xml_analyzer.py # XML布局分析模块
|
||||
├── 👁️ visual_comparator.py # 视觉比对分析模块
|
||||
├── 📊 test_reporter.py # 测试报告生成模块
|
||||
├── 📋 requirements.txt # 项目依赖
|
||||
├── 📖 README.md # 项目文档
|
||||
├── 📁 screenshots/ # 截图保存目录
|
||||
├── 📁 xml_layouts/ # XML布局保存目录
|
||||
├── 📁 design_references/ # 设计参考图目录 (SVG格式)
|
||||
├── 📁 visual_comparisons/ # 视觉对比结果目录
|
||||
└── 📁 reports/ # 测试报告目录
|
||||
```
|
||||
|
||||
### 🔧 模块功能详解
|
||||
|
||||
#### 📋 Config (config.py)
|
||||
- 统一配置管理
|
||||
- Appium连接设置
|
||||
- 目录路径配置
|
||||
- 热键映射定义
|
||||
- 分析参数调优
|
||||
|
||||
#### 🔍 XMLAnalyzer (xml_analyzer.py)
|
||||
- XML结构解析
|
||||
- SVG文件解析和元素提取
|
||||
- XML-SVG布局结构比对
|
||||
- 无障碍性问题检测
|
||||
- 重复ID识别
|
||||
- 空文本元素检查
|
||||
- 元素统计分析
|
||||
- 数据导出 (JSON/CSV)
|
||||
|
||||
#### 👁️ VisualComparator (visual_comparator.py)
|
||||
- 多格式图像支持 (PNG/JPG/SVG)
|
||||
- 智能图像尺寸处理(保持宽高比)
|
||||
- SSIM相似度计算
|
||||
- 差异区域检测
|
||||
- 自动图像调整
|
||||
- 批量比对处理
|
||||
- 结果可视化
|
||||
|
||||
#### 📊 TestReporter (test_reporter.py)
|
||||
- 专业HTML报告
|
||||
- JSON数据导出
|
||||
- 多维度分析
|
||||
- 性能数据统计
|
||||
- 问题汇总建议
|
||||
- 自定义模板支持
|
||||
|
||||
## 🛠️ 安装配置
|
||||
|
||||
### 环境要求
|
||||
- Python 3.8+
|
||||
- Android SDK
|
||||
- Appium Server
|
||||
|
||||
### 依赖安装
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 主要依赖
|
||||
```
|
||||
Appium-Python-Client>=3.1.0 # Appium客户端
|
||||
keyboard>=0.13.5 # 热键支持
|
||||
opencv-python>=4.8.0 # 图像处理
|
||||
scikit-image>=0.21.0 # 图像分析
|
||||
lxml>=4.9.0 # XML解析
|
||||
jinja2>=3.1.0 # 报告模板
|
||||
cairosvg>=2.7.0 # SVG处理
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 启动Appium服务器
|
||||
```bash
|
||||
appium
|
||||
```
|
||||
|
||||
### 2. 连接设备
|
||||
- 连接Android设备或启动模拟器
|
||||
- 确保ADB可访问设备
|
||||
|
||||
### 3. 运行测试工具
|
||||
```bash
|
||||
python ui_test.py
|
||||
```
|
||||
|
||||
### 4. 配置应用信息
|
||||
在 `config.py` 中修改目标应用配置:
|
||||
```python
|
||||
DEVICE_CAPABILITIES = {
|
||||
'platformName': 'Android',
|
||||
'automationName': 'uiautomator2',
|
||||
'deviceName': 'your-device-name',
|
||||
'appPackage': 'com.your.app',
|
||||
'appActivity': '.MainActivity'
|
||||
}
|
||||
```
|
||||
|
||||
## 🎮 操作指南
|
||||
|
||||
### 热键操作
|
||||
| 热键 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| **F1** | 📸 截图 | 保存当前屏幕截图 |
|
||||
| **F2** | 📄 XML布局 | 保存页面XML结构 |
|
||||
| **F3** | 📱 应用信息 | 获取当前应用和Activity |
|
||||
| **F4** | 🔍 元素信息 | 显示页面元素详情 |
|
||||
| **F5** | 👁️ 视觉比对 | 纯图像视觉对比(仅外观检查) |
|
||||
| **F6** | 🔍 XML分析 | XML结构分析 + SVG布局比对 |
|
||||
| **F7** | ⚡ 性能监控 | 监控操作性能 |
|
||||
| **F8** | 🖱️ 元素点击 | 智能元素交互 |
|
||||
| **F9** | 📊 生成报告 | 创建测试报告 |
|
||||
| **Ctrl+Q** | 🚪 退出 | 安全退出程序 |
|
||||
|
||||
### 命令行操作
|
||||
```bash
|
||||
# 基础操作
|
||||
help # 显示帮助信息
|
||||
status # 显示连接状态
|
||||
reconnect # 重新连接设备
|
||||
|
||||
# 功能操作
|
||||
screenshot # 截图 (或输入 1)
|
||||
xml # 保存XML (或输入 2)
|
||||
activity # 应用信息 (或输入 3)
|
||||
elements # 元素信息 (或输入 4)
|
||||
compare # 视觉比对 - 纯图像对比 (或输入 5)
|
||||
layout # XML-SVG布局比对 - 专门的结构对比
|
||||
analyze # XML分析和布局比对 (或输入 6)
|
||||
performance # 性能监控 (或输入 7)
|
||||
click # 元素点击 (或输入 8)
|
||||
report # 生成报告 (或输入 9)
|
||||
|
||||
# 退出程序
|
||||
quit # 退出 (或输入 q)
|
||||
```
|
||||
|
||||
## 📊 功能详解
|
||||
|
||||
### 🎯 功能分工说明
|
||||
|
||||
#### 👁️ F5 - 视觉比对 (`compare`)
|
||||
- **专注功能**: 纯视觉图像比对
|
||||
- **适用场景**: 检查UI外观、颜色、布局视觉效果
|
||||
- **操作流程**:
|
||||
1. 自动截图
|
||||
2. 与设计图进行像素级比对
|
||||
3. 显示相似度和差异区域
|
||||
4. 生成可视化比对结果
|
||||
- **输出结果**: 相似度评分、差异区域标注图
|
||||
|
||||
#### 🔍 F6 - XML分析和布局比对 (`analyze`)
|
||||
- **专注功能**: XML结构分析 + SVG布局比对
|
||||
- **适用场景**: 检查页面结构、元素层级、布局逻辑
|
||||
- **操作流程**:
|
||||
1. 保存并分析XML布局
|
||||
2. 自动截图(用于报告)
|
||||
3. 如果有SVG设计图,进行结构比对
|
||||
4. 生成综合测试报告
|
||||
- **输出结果**: XML分析报告、布局匹配度、测试报告
|
||||
|
||||
#### 📐 layout命令 - 专门布局比对
|
||||
- **专注功能**: 纯XML-SVG结构比对
|
||||
- **适用场景**: 专门检查布局结构一致性
|
||||
- **操作流程**: 直接进行XML和SVG的结构对比
|
||||
- **输出结果**: 元素匹配情况、结构相似度
|
||||
|
||||
### 🔍 XML分析功能
|
||||
- **结构解析**: 完整的UI层级结构分析
|
||||
- **问题检测**:
|
||||
- 无障碍性问题 (缺少contentDescription)
|
||||
- 重复资源ID
|
||||
- 空文本元素
|
||||
- 尺寸异常元素
|
||||
- **统计信息**: 元素类型分布、层级深度等
|
||||
- **数据导出**: 支持JSON和CSV格式
|
||||
|
||||
### 👁️ 视觉比对功能
|
||||
- **多格式支持**: 专门优化SVG格式处理
|
||||
- **智能对比**:
|
||||
- SSIM结构相似性分析
|
||||
- 差异区域自动标注
|
||||
- 相似度评分
|
||||
- **批量处理**: 支持多个设计图同时比对
|
||||
- **结果可视化**: 生成直观的对比图像
|
||||
|
||||
### 📊 报告生成功能
|
||||
- **HTML报告**:
|
||||
- 专业样式设计
|
||||
- 交互式图表
|
||||
- 详细问题分析
|
||||
- 改进建议
|
||||
- **JSON数据**: 结构化数据导出
|
||||
- **多维度分析**:
|
||||
- XML结构分析
|
||||
- 视觉比对结果
|
||||
- 性能监控数据
|
||||
- 问题汇总统计
|
||||
|
||||
### ⚡ 性能监控功能
|
||||
- **响应时间**: 操作执行时间统计
|
||||
- **设备状态**: CPU、内存使用情况
|
||||
- **操作历史**: 详细的操作记录
|
||||
- **性能趋势**: 长期性能变化分析
|
||||
|
||||
## 🎯 使用场景
|
||||
|
||||
### 📱 UI测试验证
|
||||
- **视觉回归测试**: 使用F5进行纯图像对比,检查UI外观变化
|
||||
- **布局结构验证**: 使用F6进行XML-SVG结构比对,确保布局逻辑正确
|
||||
- **专项布局测试**: 使用`layout`命令专门检查XML和SVG的结构一致性
|
||||
- **性能监控**: 实时监控UI操作响应时间
|
||||
|
||||
### 🔍 问题诊断
|
||||
- **XML结构分析**: 深度解析页面层级,发现结构问题
|
||||
- **无障碍性检查**: 自动检测缺失的accessibility属性
|
||||
- **重复元素识别**: 发现重复ID和空文本元素
|
||||
- **视觉差异定位**: 精确标注UI变化区域
|
||||
|
||||
### 📊 测试报告
|
||||
- **综合分析报告**: F6生成包含XML分析和布局比对的完整报告
|
||||
- **视觉比对报告**: F5生成专门的图像对比结果
|
||||
- **性能数据统计**: 操作响应时间和设备状态监控
|
||||
- **多格式导出**: 支持JSON、CSV、HTML格式
|
||||
|
||||
### 🔄 自动化测试
|
||||
```python
|
||||
from ui_test import InteractiveUITester
|
||||
from config import Config
|
||||
|
||||
# 初始化测试器
|
||||
tester = InteractiveUITester()
|
||||
tester.connect_device()
|
||||
|
||||
# 执行测试流程
|
||||
tester.take_screenshot()
|
||||
tester.save_xml_layout()
|
||||
tester.compare_with_design()
|
||||
tester.generate_report()
|
||||
```
|
||||
|
||||
### 📊 批量分析
|
||||
```python
|
||||
# 批量视觉比对
|
||||
comparator = VisualComparator()
|
||||
results = comparator.batch_compare_screenshots()
|
||||
|
||||
# 生成综合报告
|
||||
reporter = TestReporter()
|
||||
reporter.add_visual_comparison_data(results)
|
||||
reporter.generate_html_report()
|
||||
```
|
||||
|
||||
### 🔍 深度分析
|
||||
```python
|
||||
# XML深度分析
|
||||
analyzer = XMLAnalyzer()
|
||||
analysis_result = analyzer.analyze_xml_file('layout.xml')
|
||||
issues = analyzer.check_accessibility_issues()
|
||||
stats = analyzer.get_element_statistics()
|
||||
```
|
||||
|
||||
## 🔧 高级配置
|
||||
|
||||
### 📁 目录结构
|
||||
```
|
||||
appium_ui_test/
|
||||
├── screenshots/ # 截图文件
|
||||
├── xml_layouts/ # XML布局文件
|
||||
├── designs/ # 设计图文件 (PNG/JPG/SVG)
|
||||
├── reports/ # 测试报告
|
||||
├── logs/ # 日志文件
|
||||
└── temp/ # 临时文件
|
||||
```
|
||||
|
||||
### 🎨 设计文件支持
|
||||
- **图像格式**: PNG、JPG(用于视觉比对)
|
||||
- **矢量格式**: SVG(支持视觉比对 + 布局结构比对)
|
||||
- **命名规则**: 建议使用描述性文件名
|
||||
- **SVG优势**:
|
||||
- 支持双重比对(视觉 + 结构)
|
||||
- 可缩放无损质量
|
||||
- 包含布局元素信息
|
||||
|
||||
### 自定义分析规则
|
||||
在 `config.py` 中调整分析参数:
|
||||
```python
|
||||
XML_ANALYSIS = {
|
||||
'check_accessibility': True,
|
||||
'check_duplicate_ids': True,
|
||||
'check_empty_text': True,
|
||||
'min_element_size': 10,
|
||||
'max_nesting_level': 15
|
||||
}
|
||||
```
|
||||
|
||||
### 视觉比对优化
|
||||
```python
|
||||
VISUAL_COMPARISON = {
|
||||
'similarity_threshold': 0.95,
|
||||
'diff_threshold': 50,
|
||||
'min_diff_area': 100,
|
||||
'supported_formats': ['.svg']
|
||||
}
|
||||
```
|
||||
|
||||
### 报告自定义
|
||||
```python
|
||||
REPORT = {
|
||||
'include_screenshots': True,
|
||||
'include_xml_analysis': True,
|
||||
'include_visual_comparison': True,
|
||||
'include_performance_data': True,
|
||||
'template_style': 'professional'
|
||||
}
|
||||
```
|
||||
|
||||
## 🚨 故障排除
|
||||
|
||||
### 常见问题
|
||||
1. **连接失败**
|
||||
- 检查Appium服务器状态
|
||||
- 验证设备连接和ADB访问
|
||||
- 确认设备名称配置正确
|
||||
|
||||
2. **模块导入错误**
|
||||
- 安装所有必需依赖: `pip install -r requirements.txt`
|
||||
- 检查Python版本兼容性
|
||||
- 验证虚拟环境激活状态
|
||||
|
||||
3. **视觉比对失败**
|
||||
- 确保设计图为SVG格式
|
||||
- 检查图像文件路径
|
||||
- 验证cairosvg依赖安装
|
||||
|
||||
4. **报告生成失败**
|
||||
- 检查输出目录权限
|
||||
- 验证Jinja2模板完整性
|
||||
- 确认数据格式正确性
|
||||
|
||||
### 功能选择指导
|
||||
- **只需要检查UI外观**: 使用F5(视觉比对)
|
||||
- **需要检查布局结构**: 使用F6(XML分析+布局比对)
|
||||
- **专门比对XML和SVG结构**: 使用`layout`命令
|
||||
- **SVG文件**: 自动支持双重比对(视觉+结构)
|
||||
- **PNG/JPG文件**: 仅支持视觉比对
|
||||
|
||||
### 调试模式
|
||||
启用详细日志输出:
|
||||
```python
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
```
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎提交Issue和Pull Request来改进项目!
|
||||
|
||||
### 开发环境设置
|
||||
1. Fork项目仓库
|
||||
2. 创建功能分支
|
||||
3. 安装开发依赖
|
||||
4. 运行测试套件
|
||||
5. 提交代码变更
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用MIT许可证,详见LICENSE文件。
|
||||
|
||||
---
|
||||
|
||||
**🎉 享受高效的UI测试体验!**
|
||||
113
config.py
Normal file
113
config.py
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
UI测试工具配置文件
|
||||
统一管理所有配置参数
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
class Config:
|
||||
"""配置管理类"""
|
||||
|
||||
# Appium连接配置
|
||||
APPIUM_SERVER_URL = 'http://localhost:4723'
|
||||
|
||||
# 设备配置
|
||||
DEVICE_CAPABILITIES = {
|
||||
'platformName': 'Android',
|
||||
'automationName': 'uiautomator2',
|
||||
'deviceName': 'emulator-5554',
|
||||
'appPackage': 'com.example.myapplication',
|
||||
'appActivity': '.MainActivity',
|
||||
'noReset': True,
|
||||
'newCommandTimeout': 300
|
||||
}
|
||||
|
||||
# 目录配置
|
||||
BASE_DIR = Path(".")
|
||||
SCREENSHOTS_DIR = BASE_DIR / "screenshots"
|
||||
XML_LAYOUTS_DIR = BASE_DIR / "xml_layouts"
|
||||
DESIGN_REFERENCES_DIR = BASE_DIR / "design_references"
|
||||
VISUAL_COMPARISONS_DIR = BASE_DIR / "visual_comparisons"
|
||||
REPORTS_DIR = BASE_DIR / "reports"
|
||||
|
||||
# 热键配置
|
||||
HOTKEYS = {
|
||||
'screenshot': 'F1',
|
||||
'save_xml': 'F2',
|
||||
'get_activity': 'F3',
|
||||
'element_info': 'F4',
|
||||
'visual_compare': 'F5',
|
||||
'analyze_page': 'F6',
|
||||
'monitor_performance': 'F7',
|
||||
'click_element': 'F8',
|
||||
'generate_report': 'F9',
|
||||
'quit': 'ctrl+q'
|
||||
}
|
||||
|
||||
# 视觉比对配置
|
||||
VISUAL_COMPARISON = {
|
||||
'similarity_threshold': 0.95, # 相似度阈值
|
||||
'excellent_threshold': 0.95, # 优秀相似度阈值
|
||||
'good_threshold': 0.85, # 良好相似度阈值
|
||||
'diff_threshold': 50, # 差异检测阈值
|
||||
'min_diff_area': 100, # 最小差异区域面积
|
||||
'supported_formats': ['.svg']
|
||||
}
|
||||
|
||||
# XML分析配置
|
||||
XML_ANALYSIS = {
|
||||
'min_clickable_size': 48, # 最小可点击尺寸
|
||||
'max_text_length': 50, # 最大文本长度
|
||||
'check_accessibility': True, # 检查可访问性
|
||||
'check_duplicates': True # 检查重复ID
|
||||
}
|
||||
|
||||
# 性能监控配置
|
||||
PERFORMANCE = {
|
||||
'enable_monitoring': True,
|
||||
'memory_check_interval': 5, # 内存检查间隔(秒)
|
||||
'operation_timeout': 30 # 操作超时时间(秒)
|
||||
}
|
||||
|
||||
# 报告配置
|
||||
REPORT = {
|
||||
'default_format': 'html', # 默认报告格式
|
||||
'generate_html': True, # 生成HTML报告
|
||||
'include_screenshots': True, # 包含截图
|
||||
'include_xml': True, # 包含XML分析
|
||||
'include_performance': True # 包含性能数据
|
||||
}
|
||||
|
||||
# 日志配置
|
||||
LOGGING = {
|
||||
'level': 'INFO',
|
||||
'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
'file': 'ui_test.log'
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setup_directories(cls):
|
||||
"""创建必要的目录"""
|
||||
directories = [
|
||||
cls.SCREENSHOTS_DIR,
|
||||
cls.XML_LAYOUTS_DIR,
|
||||
cls.DESIGN_REFERENCES_DIR,
|
||||
cls.VISUAL_COMPARISONS_DIR,
|
||||
cls.REPORTS_DIR
|
||||
]
|
||||
|
||||
for directory in directories:
|
||||
directory.mkdir(exist_ok=True)
|
||||
|
||||
return directories
|
||||
|
||||
@classmethod
|
||||
def get_supported_design_formats(cls):
|
||||
"""获取支持的设计文件格式"""
|
||||
return cls.VISUAL_COMPARISON['supported_formats']
|
||||
|
||||
@classmethod
|
||||
def is_svg_preferred(cls):
|
||||
"""SVG是否为首选格式"""
|
||||
return True # SVG矢量格式,文件小,可缩放
|
||||
51
design_references/sample_design.svg
Normal file
51
design_references/sample_design.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 28 KiB |
325
reports/test_report_20251031_174221.html
Normal file
325
reports/test_report_20251031_174221.html
Normal file
@@ -0,0 +1,325 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>UI测试报告</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 { margin: 0; font-size: 2.5em; }
|
||||
.header p { margin: 10px 0 0 0; opacity: 0.9; }
|
||||
|
||||
.section {
|
||||
margin: 0;
|
||||
padding: 25px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.section:last-child { border-bottom: none; }
|
||||
.section h2 {
|
||||
color: #333;
|
||||
margin-top: 0;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.issue {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
color: #856404;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 5px;
|
||||
border-left: 4px solid #f39c12;
|
||||
}
|
||||
.success {
|
||||
background: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 5px;
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 5px;
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.stat-item {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
.stat-item h3 {
|
||||
font-size: 2em;
|
||||
margin: 0;
|
||||
color: #667eea;
|
||||
}
|
||||
.stat-item p {
|
||||
margin: 5px 0 0 0;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: #e9ecef;
|
||||
border-radius: 10px;
|
||||
height: 20px;
|
||||
margin: 10px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #28a745, #20c997);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
background: white;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
tr:nth-child(even) { background: #f8f9fa; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-success { background: #d4edda; color: #155724; }
|
||||
.badge-warning { background: #fff3cd; color: #856404; }
|
||||
.badge-danger { background: #f8d7da; color: #721c24; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🤖 UI测试报告</h1>
|
||||
<p>生成时间: 2025-10-31 17:42:21</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📊 测试概览</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<h3>18</h3>
|
||||
<p>总元素数</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>1</h3>
|
||||
<p>发现问题</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>88.6%</h3>
|
||||
<p>视觉相似度</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>100%</h3>
|
||||
<p>性能评分</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<p><strong>视觉相似度进度:</strong></p>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: 89.0%"></div>
|
||||
</div>
|
||||
<p>89.0% 相似度</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📱 XML布局分析</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<h3>1</h3>
|
||||
<p>可点击元素</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>0</h3>
|
||||
<p>文本元素</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>9</h3>
|
||||
<p>最大层级深度</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h3>🔍 发现的问题:</h3>
|
||||
|
||||
<div class="issue">
|
||||
<strong>android.widget.Button</strong>: 可点击元素缺少content-desc
|
||||
|
||||
<small style="color: #666;"> (位置: 333,1518)</small>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>👁️ 视觉比对分析</h2>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<h3>88.6%</h3>
|
||||
<p>相似度得分</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>1</h3>
|
||||
<p>差异区域数量</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>244545.0</h3>
|
||||
<p>差异面积(像素)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="issue">
|
||||
⚠️ 视觉差异较大 (11.4%),建议检查UI实现
|
||||
<span class="badge badge-warning">未通过阈值检查</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>⚡ 性能数据</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>操作类型</th>
|
||||
<th>执行时间(秒)</th>
|
||||
<th>时间戳</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td>xml_analysis</td>
|
||||
<td>0.069</td>
|
||||
<td>20251031_174305</td>
|
||||
<td>
|
||||
|
||||
<span class="badge badge-success">快速</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>screenshot</td>
|
||||
<td>0.334</td>
|
||||
<td>20251031_174305</td>
|
||||
<td>
|
||||
|
||||
<span class="badge badge-success">快速</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>screenshot</td>
|
||||
<td>0.278</td>
|
||||
<td>20251031_174305</td>
|
||||
<td>
|
||||
|
||||
<span class="badge badge-success">快速</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>xml_analysis</td>
|
||||
<td>0.055</td>
|
||||
<td>20251031_174306</td>
|
||||
<td>
|
||||
|
||||
<span class="badge badge-success">快速</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📋 建议和总结</h2>
|
||||
|
||||
<div class="issue">
|
||||
🔧 发现 1 个布局问题,建议优化可访问性和用户体验
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="error">
|
||||
🎨 视觉差异超过10%,建议检查UI实现是否符合设计要求
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="success">
|
||||
✅ 测试完成,详细数据已记录。建议定期进行UI测试以确保应用质量。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
27
requirements.txt
Normal file
27
requirements.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
# 核心依赖
|
||||
Appium-Python-Client>=3.1.0
|
||||
keyboard>=0.13.5
|
||||
pathlib2>=2.3.7
|
||||
Pillow>=10.0.0
|
||||
opencv-python>=4.8.0
|
||||
numpy>=1.24.0
|
||||
lxml>=4.9.0
|
||||
jinja2>=3.1.0
|
||||
scikit-image>=0.21.0
|
||||
matplotlib>=3.7.0
|
||||
seaborn>=0.12.0
|
||||
|
||||
# SVG处理库 (选择安装其中一组)
|
||||
# 选项1: Selenium (推荐,浏览器渲染SVG,无需额外系统库)
|
||||
selenium>=4.15.0
|
||||
webdriver-manager>=4.0.0
|
||||
|
||||
# 选项2: ReportLab + svglib (在Windows上需要Cairo库,可能安装困难)
|
||||
reportlab>=4.0.0
|
||||
svglib>=1.5.0
|
||||
|
||||
# 选项3: CairoSVG (需要Cairo系统库,Windows下安装困难)
|
||||
# cairosvg>=2.7.0
|
||||
|
||||
# 选项4: Wand (需要ImageMagick,安装复杂)
|
||||
# Wand>=0.6.0
|
||||
BIN
screenshots/screenshot_20251031_174424.png
Normal file
BIN
screenshots/screenshot_20251031_174424.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
451
test_reporter.py
Normal file
451
test_reporter.py
Normal file
@@ -0,0 +1,451 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试报告生成器模块
|
||||
用于生成JSON和HTML格式的测试报告
|
||||
"""
|
||||
|
||||
import json
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from jinja2 import Template
|
||||
from config import Config
|
||||
|
||||
|
||||
class TestReporter:
|
||||
"""测试报告生成器"""
|
||||
|
||||
def __init__(self):
|
||||
self.reports_dir = Config.REPORTS_DIR
|
||||
self.reports_dir.mkdir(exist_ok=True)
|
||||
self.config = Config.REPORT
|
||||
self.test_data = {
|
||||
'timestamp': datetime.datetime.now(),
|
||||
'xml_analysis': {},
|
||||
'visual_comparison': {},
|
||||
'performance_data': {},
|
||||
'issues': [],
|
||||
'test_summary': {}
|
||||
}
|
||||
|
||||
def add_xml_analysis(self, analyzer):
|
||||
"""添加XML分析结果"""
|
||||
self.test_data['xml_analysis'] = {
|
||||
'statistics': analyzer.get_statistics(),
|
||||
'issues': analyzer.issues,
|
||||
'elements_count': len(analyzer.elements_data),
|
||||
'duplicate_ids': analyzer.find_duplicate_ids(),
|
||||
'accessibility_issues': analyzer.get_accessibility_issues()
|
||||
}
|
||||
|
||||
def add_visual_comparison(self, comparator):
|
||||
"""添加视觉比对结果"""
|
||||
if comparator.comparison_results:
|
||||
result = comparator.comparison_results[-1]
|
||||
summary = comparator.get_comparison_summary()
|
||||
|
||||
self.test_data['visual_comparison'] = {
|
||||
'similarity_score': result['similarity_score'],
|
||||
'diff_regions_count': len(result['diff_regions']),
|
||||
'diff_percentage': result['diff_percentage'],
|
||||
'total_diff_area': result['total_diff_area'],
|
||||
'meets_threshold': result['meets_threshold'],
|
||||
'summary': summary
|
||||
}
|
||||
|
||||
def add_performance_data(self, data):
|
||||
"""添加性能数据"""
|
||||
self.test_data['performance_data'] = data
|
||||
|
||||
def add_test_summary(self, summary):
|
||||
"""添加测试摘要"""
|
||||
self.test_data['test_summary'] = summary
|
||||
|
||||
def generate_report(self, format=None):
|
||||
"""生成测试报告"""
|
||||
if format is None:
|
||||
format = self.config['default_format']
|
||||
|
||||
timestamp = self.test_data['timestamp'].strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
if format == 'json':
|
||||
report_path = self.reports_dir / f"test_report_{timestamp}.json"
|
||||
self._generate_json_report(report_path)
|
||||
|
||||
elif format == 'html':
|
||||
report_path = self.reports_dir / f"test_report_{timestamp}.html"
|
||||
self._generate_html_report(report_path)
|
||||
|
||||
else:
|
||||
raise ValueError(f"不支持的报告格式: {format}")
|
||||
|
||||
print(f"📊 测试报告已生成: {report_path}")
|
||||
return report_path
|
||||
|
||||
def _generate_json_report(self, output_path):
|
||||
"""生成JSON格式报告"""
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.test_data, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
def _generate_html_report(self, output_path):
|
||||
"""生成HTML格式报告"""
|
||||
html_template = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>UI测试报告</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 { margin: 0; font-size: 2.5em; }
|
||||
.header p { margin: 10px 0 0 0; opacity: 0.9; }
|
||||
|
||||
.section {
|
||||
margin: 0;
|
||||
padding: 25px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.section:last-child { border-bottom: none; }
|
||||
.section h2 {
|
||||
color: #333;
|
||||
margin-top: 0;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.issue {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
color: #856404;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 5px;
|
||||
border-left: 4px solid #f39c12;
|
||||
}
|
||||
.success {
|
||||
background: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 5px;
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 5px;
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.stat-item {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
.stat-item h3 {
|
||||
font-size: 2em;
|
||||
margin: 0;
|
||||
color: #667eea;
|
||||
}
|
||||
.stat-item p {
|
||||
margin: 5px 0 0 0;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: #e9ecef;
|
||||
border-radius: 10px;
|
||||
height: 20px;
|
||||
margin: 10px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #28a745, #20c997);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
background: white;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
tr:nth-child(even) { background: #f8f9fa; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-success { background: #d4edda; color: #155724; }
|
||||
.badge-warning { background: #fff3cd; color: #856404; }
|
||||
.badge-danger { background: #f8d7da; color: #721c24; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🤖 UI测试报告</h1>
|
||||
<p>生成时间: {{ timestamp }}</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📊 测试概览</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<h3>{{ xml_stats.total_elements or 0 }}</h3>
|
||||
<p>总元素数</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>{{ xml_stats.issues_count or 0 }}</h3>
|
||||
<p>发现问题</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>{{ visual_similarity }}%</h3>
|
||||
<p>视觉相似度</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>{{ performance_score }}%</h3>
|
||||
<p>性能评分</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if visual_data and visual_data.similarity_score %}
|
||||
<div>
|
||||
<p><strong>视觉相似度进度:</strong></p>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {{ (visual_data.similarity_score * 100)|round }}%"></div>
|
||||
</div>
|
||||
<p>{{ (visual_data.similarity_score * 100)|round }}% 相似度</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📱 XML布局分析</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<h3>{{ xml_stats.clickable_elements or 0 }}</h3>
|
||||
<p>可点击元素</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>{{ xml_stats.text_elements or 0 }}</h3>
|
||||
<p>文本元素</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>{{ xml_stats.max_depth or 0 }}</h3>
|
||||
<p>最大层级深度</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if xml_issues %}
|
||||
<h3>🔍 发现的问题:</h3>
|
||||
{% for issue in xml_issues[:10] %}
|
||||
<div class="issue">
|
||||
<strong>{{ issue.element }}</strong>: {{ issue.issue }}
|
||||
{% if issue.bounds %}
|
||||
<small style="color: #666;"> (位置: {{ issue.bounds.x1 }},{{ issue.bounds.y1 }})</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if xml_issues|length > 10 %}
|
||||
<p><em>... 还有 {{ xml_issues|length - 10 }} 个问题未显示</em></p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="success">
|
||||
✅ 未发现XML布局问题
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>👁️ 视觉比对分析</h2>
|
||||
{% if visual_data %}
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<h3>{{ "%.1f"|format(visual_data.similarity_score * 100) }}%</h3>
|
||||
<p>相似度得分</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>{{ visual_data.diff_regions_count }}</h3>
|
||||
<p>差异区域数量</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>{{ visual_data.total_diff_area }}</h3>
|
||||
<p>差异面积(像素)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if visual_data.meets_threshold %}
|
||||
<div class="success">
|
||||
✅ 视觉效果良好,与设计图高度一致
|
||||
<span class="badge badge-success">通过阈值检查</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="issue">
|
||||
⚠️ 视觉差异较大 ({{ "%.1f"|format(visual_data.diff_percentage) }}%),建议检查UI实现
|
||||
<span class="badge badge-warning">未通过阈值检查</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="issue">
|
||||
❌ 未进行视觉比对分析
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>⚡ 性能数据</h2>
|
||||
{% if performance_data and performance_data.operations %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>操作类型</th>
|
||||
<th>执行时间(秒)</th>
|
||||
<th>时间戳</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for op in performance_data.operations[-10:] %}
|
||||
<tr>
|
||||
<td>{{ op.type }}</td>
|
||||
<td>{{ "%.3f"|format(op.time) }}</td>
|
||||
<td>{{ op.timestamp }}</td>
|
||||
<td>
|
||||
{% if op.time < 1.0 %}
|
||||
<span class="badge badge-success">快速</span>
|
||||
{% elif op.time < 3.0 %}
|
||||
<span class="badge badge-warning">正常</span>
|
||||
{% else %}
|
||||
<span class="badge badge-danger">慢</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="issue">
|
||||
❌ 无性能数据记录
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📋 建议和总结</h2>
|
||||
{% if xml_stats.issues_count and xml_stats.issues_count > 0 %}
|
||||
<div class="issue">
|
||||
🔧 发现 {{ xml_stats.issues_count }} 个布局问题,建议优化可访问性和用户体验
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if visual_data and visual_data.diff_percentage > 10 %}
|
||||
<div class="error">
|
||||
🎨 视觉差异超过10%,建议检查UI实现是否符合设计要求
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if performance_data and performance_data.operations %}
|
||||
{% set avg_time = (performance_data.operations | map(attribute='time') | sum) / (performance_data.operations | length) %}
|
||||
{% if avg_time > 2.0 %}
|
||||
<div class="issue">
|
||||
⚡ 平均操作时间较长 ({{ "%.2f"|format(avg_time) }}秒),建议优化性能
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="success">
|
||||
✅ 测试完成,详细数据已记录。建议定期进行UI测试以确保应用质量。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
template = Template(html_template)
|
||||
|
||||
xml_stats = self.test_data.get('xml_analysis', {}).get('statistics', {})
|
||||
visual_data = self.test_data.get('visual_comparison', {})
|
||||
performance_data = self.test_data.get('performance_data', {})
|
||||
|
||||
# 计算性能评分
|
||||
performance_score = 100
|
||||
if performance_data.get('operations'):
|
||||
avg_time = sum(op.get('time', 0) for op in performance_data['operations']) / len(performance_data['operations'])
|
||||
if avg_time > 3.0:
|
||||
performance_score = 60
|
||||
elif avg_time > 1.0:
|
||||
performance_score = 80
|
||||
|
||||
html_content = template.render(
|
||||
timestamp=self.test_data['timestamp'].strftime("%Y-%m-%d %H:%M:%S"),
|
||||
xml_stats=xml_stats,
|
||||
xml_issues=self.test_data.get('xml_analysis', {}).get('issues', []),
|
||||
visual_data=visual_data,
|
||||
performance_data=performance_data,
|
||||
visual_similarity=f"{visual_data.get('similarity_score', 0) * 100:.1f}" if visual_data else "N/A",
|
||||
performance_score=performance_score
|
||||
)
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
def get_report_summary(self):
|
||||
"""获取报告摘要"""
|
||||
xml_stats = self.test_data.get('xml_analysis', {}).get('statistics', {})
|
||||
visual_data = self.test_data.get('visual_comparison', {})
|
||||
|
||||
summary = {
|
||||
'total_elements': xml_stats.get('total_elements', 0),
|
||||
'issues_found': xml_stats.get('issues_count', 0),
|
||||
'visual_similarity': visual_data.get('similarity_score', 0) * 100 if visual_data else 0,
|
||||
'test_passed': xml_stats.get('issues_count', 0) == 0 and visual_data.get('meets_threshold', False)
|
||||
}
|
||||
|
||||
return summary
|
||||
666
ui_test.py
Normal file
666
ui_test.py
Normal file
@@ -0,0 +1,666 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Appium 交互式UI测试工具 - 主入口文件
|
||||
支持Android应用的UI自动化测试、XML布局分析、视觉比对等功能
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import datetime
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from appium import webdriver
|
||||
from appium.options.android import UiAutomator2Options
|
||||
from appium.webdriver.common.appiumby import AppiumBy
|
||||
import keyboard
|
||||
|
||||
# 导入自定义模块
|
||||
from config import Config
|
||||
from xml_analyzer import XMLAnalyzer
|
||||
from visual_comparator import VisualComparator
|
||||
from test_reporter import TestReporter
|
||||
|
||||
|
||||
class InteractiveUITester:
|
||||
"""交互式UI测试工具"""
|
||||
|
||||
def __init__(self):
|
||||
self.driver = None
|
||||
self.running = False
|
||||
|
||||
# 使用配置文件中的设置
|
||||
self.screenshots_dir = Config.SCREENSHOTS_DIR
|
||||
self.xml_dir = Config.XML_LAYOUTS_DIR
|
||||
self.designs_dir = Config.DESIGN_REFERENCES_DIR
|
||||
self.comparisons_dir = Config.VISUAL_COMPARISONS_DIR
|
||||
|
||||
# 设置目录
|
||||
Config.setup_directories()
|
||||
|
||||
# 初始化分析器
|
||||
self.xml_analyzer = XMLAnalyzer()
|
||||
self.visual_comparator = VisualComparator()
|
||||
self.test_reporter = TestReporter()
|
||||
|
||||
# 性能监控数据
|
||||
self.performance_data = {
|
||||
'start_time': None,
|
||||
'operations': [],
|
||||
'memory_usage': []
|
||||
}
|
||||
|
||||
# Appium配置
|
||||
self.capabilities = Config.DEVICE_CAPABILITIES
|
||||
self.appium_server_url = Config.APPIUM_SERVER_URL
|
||||
|
||||
def connect_device(self):
|
||||
"""连接到Appium服务器"""
|
||||
try:
|
||||
print("🔌 正在连接Appium服务器...")
|
||||
self.driver = webdriver.Remote(
|
||||
self.appium_server_url,
|
||||
options=UiAutomator2Options().load_capabilities(self.capabilities)
|
||||
)
|
||||
self.performance_data['start_time'] = time.time()
|
||||
print("✅ 成功连接到设备!")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ 连接失败: {e}")
|
||||
return False
|
||||
|
||||
def disconnect_device(self):
|
||||
"""断开设备连接"""
|
||||
if self.driver:
|
||||
try:
|
||||
self.driver.quit()
|
||||
print("🔌 已断开设备连接")
|
||||
except:
|
||||
pass
|
||||
self.driver = None
|
||||
|
||||
def take_screenshot(self):
|
||||
"""截图功能"""
|
||||
if not self.driver:
|
||||
print("❌ 设备未连接,无法截图")
|
||||
return None
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"screenshot_{timestamp}.png"
|
||||
filepath = self.screenshots_dir / filename
|
||||
|
||||
# 获取截图
|
||||
screenshot_base64 = self.driver.get_screenshot_as_base64()
|
||||
screenshot_data = base64.b64decode(screenshot_base64)
|
||||
|
||||
# 保存截图
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(screenshot_data)
|
||||
|
||||
# 记录性能数据
|
||||
operation_time = time.time() - start_time
|
||||
self.performance_data['operations'].append({
|
||||
'type': 'screenshot',
|
||||
'time': operation_time,
|
||||
'timestamp': timestamp
|
||||
})
|
||||
|
||||
print(f"📸 截图已保存: {filepath}")
|
||||
return filepath
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 截图失败: {e}")
|
||||
return None
|
||||
|
||||
def save_page_xml(self):
|
||||
"""保存当前页面XML布局"""
|
||||
if not self.driver:
|
||||
print("❌ 设备未连接,无法获取XML")
|
||||
return None
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"layout_{timestamp}.xml"
|
||||
filepath = self.xml_dir / filename
|
||||
|
||||
# 获取页面源码
|
||||
page_source = self.driver.page_source
|
||||
|
||||
# 保存XML
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(page_source)
|
||||
|
||||
# 分析XML
|
||||
if self.xml_analyzer.parse_xml(page_source):
|
||||
stats = self.xml_analyzer.get_statistics()
|
||||
print(f"📄 XML布局已保存: {filepath}")
|
||||
print(f"📊 分析结果: {stats['total_elements']}个元素, {stats['issues_count']}个问题")
|
||||
|
||||
# 显示主要问题
|
||||
if self.xml_analyzer.issues:
|
||||
print("⚠️ 发现的主要问题:")
|
||||
for issue in self.xml_analyzer.issues[:3]: # 只显示前3个
|
||||
print(f" • {issue['element']}: {issue['issue']}")
|
||||
if len(self.xml_analyzer.issues) > 3:
|
||||
print(f" ... 还有 {len(self.xml_analyzer.issues) - 3} 个问题")
|
||||
|
||||
# 记录性能数据
|
||||
operation_time = time.time() - start_time
|
||||
self.performance_data['operations'].append({
|
||||
'type': 'xml_analysis',
|
||||
'time': operation_time,
|
||||
'timestamp': timestamp
|
||||
})
|
||||
|
||||
return filepath
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 保存XML失败: {e}")
|
||||
return None
|
||||
|
||||
def compare_with_design(self):
|
||||
"""与设计图进行视觉比对(仅视觉比对)"""
|
||||
if not self.driver:
|
||||
print("❌ 设备未连接,无法进行比对")
|
||||
return
|
||||
|
||||
# 检查设计图目录
|
||||
supported_formats = Config.get_supported_design_formats()
|
||||
design_files = []
|
||||
for fmt in supported_formats:
|
||||
design_files.extend(list(self.designs_dir.glob(f"*{fmt}")))
|
||||
|
||||
if not design_files:
|
||||
print(f"❌ 未找到设计图,请将设计图放入 {self.designs_dir} 目录")
|
||||
print(f"💡 支持的格式: {', '.join(supported_formats)}")
|
||||
return
|
||||
|
||||
# 截图
|
||||
screenshot_path = self.take_screenshot()
|
||||
if not screenshot_path:
|
||||
return
|
||||
|
||||
try:
|
||||
# 使用最新的设计图进行比对
|
||||
design_path = max(design_files, key=lambda x: x.stat().st_mtime)
|
||||
print(f"🎨 使用设计图: {design_path.name}")
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
comparison_path = self.comparisons_dir / f"comparison_{timestamp}.png"
|
||||
|
||||
# 执行视觉比对
|
||||
print("👁️ 开始视觉比对...")
|
||||
visual_result = self.visual_comparator.compare_images(
|
||||
screenshot_path, design_path, comparison_path
|
||||
)
|
||||
|
||||
if visual_result:
|
||||
similarity = visual_result['similarity_score'] * 100
|
||||
diff_count = len(visual_result['diff_regions'])
|
||||
|
||||
print(f"📊 视觉比对结果:")
|
||||
print(f" 相似度: {similarity:.1f}%")
|
||||
print(f" 差异区域: {diff_count}个")
|
||||
print(f" 比对结果: {comparison_path}")
|
||||
|
||||
if similarity >= Config.VISUAL_COMPARISON['excellent_threshold'] * 100:
|
||||
print("✅ 视觉效果优秀,与设计高度一致")
|
||||
elif similarity >= Config.VISUAL_COMPARISON['good_threshold'] * 100:
|
||||
print("⚠️ 视觉效果良好,有轻微差异")
|
||||
else:
|
||||
print("❌ 视觉差异较大,建议检查实现")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 视觉比对失败: {e}")
|
||||
|
||||
def compare_layout_only(self):
|
||||
"""仅进行XML-SVG布局比对"""
|
||||
if not self.driver:
|
||||
print("❌ 设备未连接,无法进行比对")
|
||||
return
|
||||
|
||||
# 检查SVG设计图
|
||||
svg_files = list(self.designs_dir.glob("*.svg"))
|
||||
if not svg_files:
|
||||
print(f"❌ 未找到SVG设计图,请将SVG文件放入 {self.designs_dir} 目录")
|
||||
return
|
||||
|
||||
# 保存当前页面XML
|
||||
xml_path = self.save_page_xml()
|
||||
if not xml_path:
|
||||
return
|
||||
|
||||
try:
|
||||
# 使用最新的SVG设计图
|
||||
svg_path = max(svg_files, key=lambda x: x.stat().st_mtime)
|
||||
print(f"📐 使用SVG设计图: {svg_path.name}")
|
||||
|
||||
# 执行布局比对
|
||||
layout_result = self.xml_analyzer.compare_layouts(str(svg_path))
|
||||
|
||||
if layout_result:
|
||||
print(f"\n📊 布局比对详细结果:")
|
||||
print(f" XML元素数量: {layout_result['xml_elements_count']}")
|
||||
print(f" SVG元素数量: {layout_result['svg_elements_count']}")
|
||||
print(f" 匹配元素数量: {len(layout_result['matched_elements'])}")
|
||||
print(f" 布局相似度: {layout_result['layout_similarity']:.1%}")
|
||||
|
||||
# 显示匹配详情
|
||||
if layout_result['matched_elements']:
|
||||
print(f"\n✅ 匹配的元素:")
|
||||
for i, match in enumerate(layout_result['matched_elements'][:5]): # 只显示前5个
|
||||
xml_elem = match['xml_element']
|
||||
svg_elem = match['svg_element']
|
||||
print(f" {i+1}. {xml_elem['type']} ↔ {svg_elem['type']} (相似度: {match['similarity']:.1%})")
|
||||
if xml_elem.get('text') or svg_elem.get('text'):
|
||||
print(f" 文本: '{xml_elem.get('text', '')}' ↔ '{svg_elem.get('text', '')}'")
|
||||
|
||||
# 显示未匹配元素
|
||||
if layout_result['unmatched_xml']:
|
||||
print(f"\n⚠️ 未匹配的XML元素 ({len(layout_result['unmatched_xml'])}个):")
|
||||
for i, elem in enumerate(layout_result['unmatched_xml'][:3]): # 只显示前3个
|
||||
print(f" {i+1}. {elem['type']} - '{elem.get('text', '无文本')}'")
|
||||
|
||||
if layout_result['unmatched_svg']:
|
||||
print(f"\n⚠️ 未匹配的SVG元素 ({len(layout_result['unmatched_svg'])}个):")
|
||||
for i, elem in enumerate(layout_result['unmatched_svg'][:3]): # 只显示前3个
|
||||
print(f" {i+1}. {elem['type']} - '{elem.get('text', '无文本')}'")
|
||||
|
||||
# 总结
|
||||
print(f"\n{self.xml_analyzer.get_layout_comparison_summary()}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 布局比对失败: {e}")
|
||||
|
||||
def analyze_current_page(self):
|
||||
"""分析当前页面(XML分析和布局比对)"""
|
||||
if not self.driver:
|
||||
print("❌ 设备未连接")
|
||||
return
|
||||
|
||||
print("🔍 开始综合页面分析...")
|
||||
|
||||
# 1. 保存XML并分析
|
||||
xml_path = self.save_page_xml()
|
||||
|
||||
# 2. 截图
|
||||
screenshot_path = self.take_screenshot()
|
||||
|
||||
# 3. 检查是否有SVG设计图进行布局比对
|
||||
supported_formats = Config.get_supported_design_formats()
|
||||
design_files = []
|
||||
for fmt in supported_formats:
|
||||
design_files.extend(list(self.designs_dir.glob(f"*{fmt}")))
|
||||
|
||||
# 查找SVG设计图
|
||||
svg_files = [f for f in design_files if f.suffix.lower() == '.svg']
|
||||
|
||||
if svg_files:
|
||||
print("\n📐 发现SVG设计图,开始布局比对分析...")
|
||||
# 使用最新的SVG设计图
|
||||
svg_design = max(svg_files, key=lambda x: x.stat().st_mtime)
|
||||
print(f"🎨 使用SVG设计图: {svg_design.name}")
|
||||
|
||||
# 执行XML-SVG布局比对
|
||||
print("🔍 开始XML-SVG布局比对...")
|
||||
layout_result = self.xml_analyzer.compare_layouts(str(svg_design))
|
||||
|
||||
if layout_result:
|
||||
print(f"📐 布局比对结果:")
|
||||
print(f" XML元素: {layout_result['xml_elements_count']}个")
|
||||
print(f" SVG元素: {layout_result['svg_elements_count']}个")
|
||||
print(f" 匹配元素: {len(layout_result['matched_elements'])}个")
|
||||
print(f" 布局相似度: {layout_result['layout_similarity']:.1%}")
|
||||
|
||||
if layout_result['layout_similarity'] >= 0.8:
|
||||
print("✅ 布局高度一致")
|
||||
elif layout_result['layout_similarity'] >= 0.6:
|
||||
print("⚠️ 布局基本一致,有少量差异")
|
||||
else:
|
||||
print("❌ 布局差异较大,建议检查")
|
||||
|
||||
# 显示未匹配的元素
|
||||
if layout_result['unmatched_xml']:
|
||||
print(f" 未匹配XML元素: {len(layout_result['unmatched_xml'])}个")
|
||||
if layout_result['unmatched_svg']:
|
||||
print(f" 未匹配SVG元素: {len(layout_result['unmatched_svg'])}个")
|
||||
else:
|
||||
print("\n💡 未发现SVG设计图,跳过布局比对分析")
|
||||
print(f" 提示:将SVG设计图放入 {self.designs_dir} 目录可进行布局比对")
|
||||
|
||||
# 4. 生成报告
|
||||
print("\n📋 生成测试报告...")
|
||||
self.generate_test_report()
|
||||
|
||||
def generate_test_report(self):
|
||||
"""生成测试报告"""
|
||||
try:
|
||||
# 添加分析数据到报告
|
||||
self.test_reporter.add_xml_analysis(self.xml_analyzer)
|
||||
self.test_reporter.add_visual_comparison(self.visual_comparator)
|
||||
self.test_reporter.add_performance_data(self.performance_data)
|
||||
|
||||
# 生成报告
|
||||
report_format = Config.REPORT['default_format']
|
||||
report_path = self.test_reporter.generate_report(report_format)
|
||||
|
||||
# 如果配置了生成HTML报告,也生成HTML版本
|
||||
if Config.REPORT['generate_html'] and report_format != 'html':
|
||||
html_report = self.test_reporter.generate_report('html')
|
||||
print(f"📊 HTML报告: {html_report}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 生成报告失败: {e}")
|
||||
|
||||
def find_and_click_element(self):
|
||||
"""智能元素定位和点击"""
|
||||
if not self.driver:
|
||||
print("❌ 设备未连接")
|
||||
return
|
||||
|
||||
try:
|
||||
# 获取所有可点击元素
|
||||
clickable_elements = self.driver.find_elements(AppiumBy.XPATH, '//*[@clickable="true"]')
|
||||
|
||||
if not clickable_elements:
|
||||
print("❌ 未找到可点击元素")
|
||||
return
|
||||
|
||||
print(f"🔘 找到 {len(clickable_elements)} 个可点击元素:")
|
||||
|
||||
for i, element in enumerate(clickable_elements[:10]): # 显示前10个
|
||||
try:
|
||||
text = element.text or element.get_attribute('content-desc') or f"元素{i+1}"
|
||||
bounds = element.get_attribute('bounds')
|
||||
print(f" {i+1}. {text} - {bounds}")
|
||||
except:
|
||||
print(f" {i+1}. 未知元素")
|
||||
|
||||
# 让用户选择要点击的元素
|
||||
try:
|
||||
choice = input("\n请输入要点击的元素编号 (1-10, 或按Enter跳过): ").strip()
|
||||
if choice and choice.isdigit():
|
||||
index = int(choice) - 1
|
||||
if 0 <= index < len(clickable_elements):
|
||||
element = clickable_elements[index]
|
||||
element.click()
|
||||
print(f"✅ 已点击元素 {choice}")
|
||||
time.sleep(1) # 等待页面响应
|
||||
else:
|
||||
print("❌ 无效的元素编号")
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 元素定位失败: {e}")
|
||||
|
||||
def monitor_performance(self):
|
||||
"""性能监控"""
|
||||
if not self.driver:
|
||||
print("❌ 设备未连接")
|
||||
return
|
||||
|
||||
try:
|
||||
# 获取设备信息
|
||||
device_info = {
|
||||
'platform': self.driver.capabilities.get('platformName'),
|
||||
'device_name': self.driver.capabilities.get('deviceName'),
|
||||
'app_package': self.driver.current_package,
|
||||
'app_activity': self.driver.current_activity
|
||||
}
|
||||
|
||||
# 计算运行时间
|
||||
if self.performance_data['start_time']:
|
||||
runtime = time.time() - self.performance_data['start_time']
|
||||
print(f"⏱️ 性能监控:")
|
||||
print(f" 运行时间: {runtime:.1f}秒")
|
||||
print(f" 操作次数: {len(self.performance_data['operations'])}")
|
||||
|
||||
if self.performance_data['operations']:
|
||||
avg_time = sum(op['time'] for op in self.performance_data['operations']) / len(self.performance_data['operations'])
|
||||
print(f" 平均操作时间: {avg_time:.2f}秒")
|
||||
|
||||
print(f" 当前应用: {device_info['app_package']}")
|
||||
print(f" 当前Activity: {device_info['app_activity']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 性能监控失败: {e}")
|
||||
|
||||
def get_current_activity(self):
|
||||
"""获取当前Activity信息"""
|
||||
if not self.driver:
|
||||
print("❌ 设备未连接")
|
||||
return
|
||||
|
||||
try:
|
||||
current_activity = self.driver.current_activity
|
||||
current_package = self.driver.current_package
|
||||
print(f"📱 当前应用: {current_package}")
|
||||
print(f"📱 当前Activity: {current_activity}")
|
||||
except Exception as e:
|
||||
print(f"❌ 获取Activity信息失败: {e}")
|
||||
|
||||
def find_elements_info(self):
|
||||
"""显示当前页面元素信息"""
|
||||
if not self.driver:
|
||||
print("❌ 设备未连接")
|
||||
return
|
||||
|
||||
try:
|
||||
# 查找所有可点击元素
|
||||
clickable_elements = self.driver.find_elements(AppiumBy.XPATH, '//*[@clickable="true"]')
|
||||
print(f"🔘 找到 {len(clickable_elements)} 个可点击元素")
|
||||
|
||||
# 查找所有文本元素
|
||||
text_elements = self.driver.find_elements(AppiumBy.XPATH, '//*[@text!=""]')
|
||||
print(f"📝 找到 {len(text_elements)} 个文本元素")
|
||||
|
||||
# 显示前5个文本元素
|
||||
print("📝 前5个文本元素:")
|
||||
for i, element in enumerate(text_elements[:5]):
|
||||
try:
|
||||
text = element.text
|
||||
bounds = element.get_attribute('bounds')
|
||||
print(f" {i+1}. '{text}' - 位置: {bounds}")
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 获取元素信息失败: {e}")
|
||||
|
||||
def setup_hotkeys(self):
|
||||
"""设置热键监听"""
|
||||
print("⌨️ 设置热键监听...")
|
||||
|
||||
# 从配置文件获取热键设置
|
||||
hotkeys = Config.HOTKEYS
|
||||
|
||||
# 基础功能热键
|
||||
keyboard.add_hotkey(hotkeys['screenshot'], self.take_screenshot)
|
||||
print(f" {hotkeys['screenshot'].upper()} - 截图")
|
||||
|
||||
keyboard.add_hotkey(hotkeys['save_xml'], self.save_page_xml)
|
||||
print(f" {hotkeys['save_xml'].upper()} - 保存XML布局")
|
||||
|
||||
keyboard.add_hotkey(hotkeys['get_activity'], self.get_current_activity)
|
||||
print(f" {hotkeys['get_activity'].upper()} - 获取当前Activity")
|
||||
|
||||
keyboard.add_hotkey(hotkeys['element_info'], self.find_elements_info)
|
||||
print(f" {hotkeys['element_info'].upper()} - 显示页面元素信息")
|
||||
|
||||
# 新增功能热键
|
||||
keyboard.add_hotkey(hotkeys['visual_compare'], self.compare_with_design)
|
||||
print(f" {hotkeys['visual_compare'].upper()} - 视觉比对")
|
||||
|
||||
keyboard.add_hotkey(hotkeys['analyze_page'], self.analyze_current_page)
|
||||
print(f" {hotkeys['analyze_page'].upper()} - 综合页面分析")
|
||||
|
||||
keyboard.add_hotkey(hotkeys['monitor_performance'], self.monitor_performance)
|
||||
print(f" {hotkeys['monitor_performance'].upper()} - 性能监控")
|
||||
|
||||
keyboard.add_hotkey(hotkeys['click_element'], self.find_and_click_element)
|
||||
print(f" {hotkeys['click_element'].upper()} - 智能元素点击")
|
||||
|
||||
keyboard.add_hotkey(hotkeys['generate_report'], self.generate_test_report)
|
||||
print(f" {hotkeys['generate_report'].upper()} - 生成测试报告")
|
||||
|
||||
# 退出热键
|
||||
keyboard.add_hotkey(hotkeys['quit'], self.stop)
|
||||
print(f" {hotkeys['quit'].upper()} - 退出程序")
|
||||
|
||||
def show_menu(self):
|
||||
"""显示操作菜单"""
|
||||
hotkeys = Config.HOTKEYS
|
||||
supported_formats = Config.get_supported_design_formats()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("🤖 Appium 交互式UI测试工具")
|
||||
print("="*60)
|
||||
print("热键操作:")
|
||||
print(f" {hotkeys['screenshot'].upper()} - 截图")
|
||||
print(f" {hotkeys['save_xml'].upper()} - 保存XML布局")
|
||||
print(f" {hotkeys['get_activity'].upper()} - 获取当前Activity")
|
||||
print(f" {hotkeys['element_info'].upper()} - 显示页面元素信息")
|
||||
print(f" {hotkeys['visual_compare'].upper()} - 视觉比对(仅图像对比)")
|
||||
print(f" {hotkeys['analyze_page'].upper()} - XML分析和布局比对")
|
||||
print(f" {hotkeys['monitor_performance'].upper()} - 性能监控")
|
||||
print(f" {hotkeys['click_element'].upper()} - 智能元素点击")
|
||||
print(f" {hotkeys['generate_report'].upper()} - 生成测试报告")
|
||||
print(f" {hotkeys['quit'].upper()} - 退出程序")
|
||||
print("\n命令行操作:")
|
||||
print(" help - 显示帮助")
|
||||
print(" status - 显示连接状态")
|
||||
print(" reconnect - 重新连接设备")
|
||||
print(" compare - 视觉比对(仅图像对比)")
|
||||
print(" layout - XML-SVG布局比对")
|
||||
print(" analyze - XML分析和布局比对")
|
||||
print(" performance - 性能监控")
|
||||
print(" click - 智能元素点击")
|
||||
print(" report - 生成测试报告")
|
||||
print(" quit - 退出程序")
|
||||
print("\n💡 使用提示:")
|
||||
print(f" • F5/compare: 纯视觉图像比对,检查UI外观")
|
||||
print(f" • F6/analyze: XML结构分析 + SVG布局比对")
|
||||
print(f" • layout: 专门的XML-SVG布局结构比对")
|
||||
print(f" • 将设计图放入 {self.designs_dir} 目录")
|
||||
print(f" • 支持的设计图格式: {', '.join(supported_formats)}")
|
||||
print(" • 测试报告会自动保存为JSON和HTML格式")
|
||||
print("="*60)
|
||||
|
||||
def handle_command(self, command):
|
||||
"""处理命令行输入"""
|
||||
command = command.strip().lower()
|
||||
|
||||
if command == 'help':
|
||||
self.show_menu()
|
||||
elif command == 'status':
|
||||
if self.driver:
|
||||
print("✅ 设备已连接")
|
||||
self.get_current_activity()
|
||||
else:
|
||||
print("❌ 设备未连接")
|
||||
elif command == 'reconnect':
|
||||
self.disconnect_device()
|
||||
self.connect_device()
|
||||
elif command in ['quit', 'exit', 'q']:
|
||||
self.stop()
|
||||
elif command == '1' or command == 'screenshot':
|
||||
self.take_screenshot()
|
||||
elif command == '2' or command == 'xml':
|
||||
self.save_page_xml()
|
||||
elif command == '3' or command == 'activity':
|
||||
self.get_current_activity()
|
||||
elif command == '4' or command == 'elements':
|
||||
self.find_elements_info()
|
||||
elif command == '5' or command == 'compare':
|
||||
self.compare_with_design()
|
||||
elif command == 'layout':
|
||||
self.compare_layout_only()
|
||||
elif command == '6' or command == 'analyze':
|
||||
self.analyze_current_page()
|
||||
elif command == '7' or command == 'performance':
|
||||
self.monitor_performance()
|
||||
elif command == '8' or command == 'click':
|
||||
self.find_and_click_element()
|
||||
elif command == '9' or command == 'report':
|
||||
self.generate_test_report()
|
||||
else:
|
||||
print(f"❓ 未知命令: {command}")
|
||||
print("输入 'help' 查看可用命令")
|
||||
|
||||
def command_loop(self):
|
||||
"""命令行输入循环"""
|
||||
while self.running:
|
||||
try:
|
||||
command = input("\n> ").strip()
|
||||
if command:
|
||||
self.handle_command(command)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
self.stop()
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"❌ 命令执行错误: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""停止程序"""
|
||||
print("\n🛑 正在退出...")
|
||||
self.running = False
|
||||
self.disconnect_device()
|
||||
|
||||
def run(self):
|
||||
"""运行交互式测试工具"""
|
||||
print("🚀 启动Appium交互式UI测试工具")
|
||||
|
||||
# 连接设备
|
||||
if not self.connect_device():
|
||||
print("❌ 无法连接到设备,请检查Appium服务器是否启动")
|
||||
return
|
||||
|
||||
# 设置热键
|
||||
self.setup_hotkeys()
|
||||
|
||||
# 显示菜单
|
||||
self.show_menu()
|
||||
|
||||
# 标记为运行状态
|
||||
self.running = True
|
||||
|
||||
# 启动命令行输入线程
|
||||
command_thread = threading.Thread(target=self.command_loop, daemon=True)
|
||||
command_thread.start()
|
||||
|
||||
print("\n✅ 工具已启动! 使用热键或输入命令进行操作...")
|
||||
print("💡 提示: 输入 'help' 查看所有可用命令")
|
||||
|
||||
try:
|
||||
# 保持主线程运行
|
||||
while self.running:
|
||||
time.sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
self.stop()
|
||||
|
||||
print("👋 程序已退出")
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print("🎯 交互式UI测试工具")
|
||||
print("📱 支持Android应用UI测试")
|
||||
print("🔧 集成XML分析、视觉比对、性能监控等功能")
|
||||
print("-" * 50)
|
||||
|
||||
tester = InteractiveUITester()
|
||||
|
||||
try:
|
||||
tester.run()
|
||||
except Exception as e:
|
||||
print(f"❌ 程序运行出错: {e}")
|
||||
finally:
|
||||
print("🔚 程序已退出")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
459
visual_comparator.py
Normal file
459
visual_comparator.py
Normal file
@@ -0,0 +1,459 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
视觉比对器模块
|
||||
用于比较截图与设计图的视觉差异,支持PNG、JPG和SVG格式
|
||||
"""
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from skimage.metrics import structural_similarity as ssim
|
||||
from PIL import Image
|
||||
from config import Config
|
||||
|
||||
# 条件导入cairosvg,如果失败则使用备用方案
|
||||
CAIROSVG_AVAILABLE = False
|
||||
try:
|
||||
import cairosvg
|
||||
CAIROSVG_AVAILABLE = True
|
||||
print("✅ CairoSVG库可用,支持完整SVG处理")
|
||||
except (ImportError, OSError) as e:
|
||||
print(f"⚠️ CairoSVG库不可用: {e}")
|
||||
print("💡 将使用备用的SVG处理方案")
|
||||
|
||||
# 条件导入selenium,用于SVG渲染
|
||||
SELENIUM_AVAILABLE = False
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.common.by import By
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
import base64
|
||||
SELENIUM_AVAILABLE = True
|
||||
print("✅ Selenium库可用,支持浏览器SVG渲染")
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Selenium库不可用: {e}")
|
||||
print("💡 将跳过浏览器SVG渲染方案")
|
||||
|
||||
|
||||
class VisualComparator:
|
||||
"""视觉比对器"""
|
||||
|
||||
def __init__(self):
|
||||
self.comparison_results = []
|
||||
self.config = Config.VISUAL_COMPARISON
|
||||
|
||||
def compare_images(self, screenshot_path, design_path, output_path=None):
|
||||
"""比较截图和设计图"""
|
||||
try:
|
||||
# 读取图像
|
||||
screenshot = self._load_image(screenshot_path)
|
||||
design = self._load_image(design_path)
|
||||
|
||||
if screenshot is None or design is None:
|
||||
print(f"❌ 无法加载图像: {screenshot_path} 或 {design_path}")
|
||||
return None
|
||||
|
||||
# 智能调整图像尺寸,保持宽高比
|
||||
screenshot_processed, design_processed = self._smart_resize_images(screenshot, design)
|
||||
|
||||
# 转换为灰度图
|
||||
gray1 = cv2.cvtColor(screenshot_processed, cv2.COLOR_BGR2GRAY)
|
||||
gray2 = cv2.cvtColor(design_processed, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# 计算结构相似性
|
||||
similarity_score, diff_image = ssim(gray1, gray2, full=True)
|
||||
diff_image = (diff_image * 255).astype(np.uint8)
|
||||
|
||||
# 查找差异区域
|
||||
diff_regions = self._find_diff_regions(diff_image)
|
||||
|
||||
# 生成比对结果图
|
||||
if output_path:
|
||||
self._generate_comparison_image(
|
||||
screenshot_processed, design_processed, diff_image,
|
||||
diff_regions, similarity_score, output_path
|
||||
)
|
||||
|
||||
result = {
|
||||
'similarity_score': similarity_score,
|
||||
'diff_regions': diff_regions,
|
||||
'total_diff_area': sum(r['area'] for r in diff_regions),
|
||||
'diff_percentage': (1 - similarity_score) * 100,
|
||||
'meets_threshold': similarity_score >= self.config['similarity_threshold'],
|
||||
'original_screenshot_size': screenshot.shape[:2],
|
||||
'original_design_size': design.shape[:2],
|
||||
'processed_size': screenshot_processed.shape[:2]
|
||||
}
|
||||
|
||||
self.comparison_results.append(result)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 图像比对失败: {e}")
|
||||
return None
|
||||
|
||||
def _load_image(self, image_path):
|
||||
"""加载图像,支持PNG、JPG和SVG格式"""
|
||||
image_path = Path(image_path)
|
||||
|
||||
if not image_path.exists():
|
||||
print(f"❌ 文件不存在: {image_path}")
|
||||
return None
|
||||
|
||||
# 检查文件格式
|
||||
if image_path.suffix.lower() == '.svg':
|
||||
return self._load_svg_image(image_path)
|
||||
else:
|
||||
# 加载常规图像格式
|
||||
img = cv2.imread(str(image_path))
|
||||
if img is None:
|
||||
print(f"❌ 无法读取图像文件: {image_path}")
|
||||
return img
|
||||
|
||||
def _load_svg_image(self, svg_path):
|
||||
"""加载SVG图像并转换为OpenCV格式"""
|
||||
import io
|
||||
|
||||
if CAIROSVG_AVAILABLE:
|
||||
# 优先使用cairosvg
|
||||
try:
|
||||
# 将SVG转换为PNG字节数据
|
||||
png_data = cairosvg.svg2png(url=str(svg_path))
|
||||
|
||||
# 使用PIL加载PNG数据
|
||||
pil_image = Image.open(io.BytesIO(png_data))
|
||||
|
||||
# 转换为OpenCV格式
|
||||
opencv_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
|
||||
|
||||
return opencv_image
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ CairoSVG处理失败: {e}")
|
||||
|
||||
# 备用方案1: 尝试使用wand (ImageMagick)
|
||||
try:
|
||||
from wand.image import Image as WandImage
|
||||
|
||||
with WandImage(filename=str(svg_path)) as img:
|
||||
img.format = 'png'
|
||||
blob = img.make_blob()
|
||||
|
||||
pil_image = Image.open(io.BytesIO(blob))
|
||||
opencv_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
|
||||
|
||||
print("✅ 使用Wand处理SVG成功")
|
||||
return opencv_image
|
||||
|
||||
except ImportError:
|
||||
print("⚠️ Wand库不可用")
|
||||
except Exception as e:
|
||||
print(f"❌ Wand处理失败: {e}")
|
||||
|
||||
# 备用方案2: 尝试使用reportlab和svglib
|
||||
try:
|
||||
from reportlab.graphics import renderPM
|
||||
from svglib.svglib import renderSVG
|
||||
|
||||
drawing = renderSVG(str(svg_path))
|
||||
pil_image = renderPM.drawToPIL(drawing)
|
||||
opencv_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
|
||||
|
||||
print("✅ 使用ReportLab处理SVG成功")
|
||||
return opencv_image
|
||||
|
||||
except ImportError:
|
||||
print("⚠️ ReportLab/svglib库不可用")
|
||||
except Exception as e:
|
||||
print(f"❌ ReportLab处理失败: {e}")
|
||||
|
||||
# 备用方案3: 尝试使用Selenium浏览器渲染
|
||||
if SELENIUM_AVAILABLE:
|
||||
try:
|
||||
result = self._render_svg_with_selenium(svg_path)
|
||||
if result is not None:
|
||||
print("✅ 使用Selenium浏览器渲染SVG成功")
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"❌ Selenium渲染失败: {e}")
|
||||
|
||||
# 备用方案4: 简单的文本提示图像
|
||||
print(f"⚠️ 无法处理SVG文件: {svg_path}")
|
||||
print("💡 建议安装以下任一库来支持SVG:")
|
||||
print(" - pip install cairosvg (推荐,但需要Cairo库)")
|
||||
print(" - pip install Wand (需要ImageMagick)")
|
||||
print(" - pip install reportlab svglib")
|
||||
|
||||
# 创建一个提示图像
|
||||
placeholder_img = np.ones((400, 600, 3), dtype=np.uint8) * 240
|
||||
cv2.putText(placeholder_img, "SVG Not Supported", (150, 180),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 1, (100, 100, 100), 2)
|
||||
cv2.putText(placeholder_img, "Install SVG library", (150, 220),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 1, (100, 100, 100), 2)
|
||||
|
||||
return placeholder_img
|
||||
|
||||
def _render_svg_with_selenium(self, svg_path):
|
||||
"""使用Selenium浏览器渲染SVG"""
|
||||
try:
|
||||
# 配置Chrome选项
|
||||
chrome_options = Options()
|
||||
chrome_options.add_argument('--headless') # 无头模式
|
||||
chrome_options.add_argument('--no-sandbox')
|
||||
chrome_options.add_argument('--disable-dev-shm-usage')
|
||||
chrome_options.add_argument('--disable-gpu')
|
||||
chrome_options.add_argument('--disable-web-security')
|
||||
chrome_options.add_argument('--allow-running-insecure-content')
|
||||
chrome_options.add_argument('--disable-extensions')
|
||||
chrome_options.add_argument('--window-size=1200,800')
|
||||
|
||||
# 创建WebDriver
|
||||
service = Service(ChromeDriverManager().install())
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
|
||||
try:
|
||||
# 读取SVG文件内容
|
||||
with open(svg_path, 'r', encoding='utf-8') as f:
|
||||
svg_content = f.read()
|
||||
|
||||
# 创建HTML页面包含SVG
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{ margin: 0; padding: 20px; background: white; }}
|
||||
svg {{ max-width: 100%; height: auto; display: block; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{svg_content}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# 创建临时HTML文件
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
temp_html_path = f.name
|
||||
|
||||
try:
|
||||
# 使用file:// URL加载本地文件
|
||||
file_url = f"file:///{temp_html_path.replace(os.sep, '/')}"
|
||||
driver.get(file_url)
|
||||
|
||||
# 等待页面加载
|
||||
import time
|
||||
time.sleep(3)
|
||||
|
||||
# 获取SVG元素
|
||||
svg_element = driver.find_element(By.TAG_NAME, "svg")
|
||||
|
||||
# 截图SVG元素
|
||||
screenshot = svg_element.screenshot_as_png
|
||||
|
||||
# 转换为OpenCV格式
|
||||
import io
|
||||
pil_image = Image.open(io.BytesIO(screenshot))
|
||||
opencv_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
|
||||
|
||||
return opencv_image
|
||||
|
||||
finally:
|
||||
# 清理临时文件
|
||||
try:
|
||||
os.unlink(temp_html_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Selenium渲染SVG时出错: {e}")
|
||||
return None
|
||||
|
||||
def _smart_resize_images(self, img1, img2):
|
||||
"""智能调整图像尺寸,保持宽高比"""
|
||||
h1, w1 = img1.shape[:2]
|
||||
h2, w2 = img2.shape[:2]
|
||||
|
||||
# 计算宽高比
|
||||
ratio1 = w1 / h1
|
||||
ratio2 = w2 / h2
|
||||
|
||||
# 如果宽高比相近(差异小于10%),则按较小的尺寸调整
|
||||
if abs(ratio1 - ratio2) / max(ratio1, ratio2) < 0.1:
|
||||
# 选择较小的尺寸作为目标尺寸
|
||||
target_w = min(w1, w2)
|
||||
target_h = min(h1, h2)
|
||||
|
||||
img1_resized = cv2.resize(img1, (target_w, target_h))
|
||||
img2_resized = cv2.resize(img2, (target_w, target_h))
|
||||
|
||||
return img1_resized, img2_resized
|
||||
|
||||
# 如果宽高比差异较大,则保持原始比例,按最大公共区域对齐
|
||||
else:
|
||||
# 计算最大公共尺寸,保持各自的宽高比
|
||||
if ratio1 > ratio2: # img1更宽
|
||||
# 以img2的高度为基准
|
||||
new_h = min(h1, h2)
|
||||
new_w1 = int(new_h * ratio1)
|
||||
new_w2 = int(new_h * ratio2)
|
||||
else: # img2更宽或相等
|
||||
# 以img1的高度为基准
|
||||
new_h = min(h1, h2)
|
||||
new_w1 = int(new_h * ratio1)
|
||||
new_w2 = int(new_h * ratio2)
|
||||
|
||||
# 调整图像尺寸
|
||||
img1_resized = cv2.resize(img1, (new_w1, new_h))
|
||||
img2_resized = cv2.resize(img2, (new_w2, new_h))
|
||||
|
||||
# 如果宽度不同,则创建相同宽度的画布,居中放置
|
||||
max_w = max(new_w1, new_w2)
|
||||
|
||||
if new_w1 != max_w:
|
||||
canvas1 = np.ones((new_h, max_w, 3), dtype=np.uint8) * 255
|
||||
offset = (max_w - new_w1) // 2
|
||||
canvas1[:, offset:offset+new_w1] = img1_resized
|
||||
img1_resized = canvas1
|
||||
|
||||
if new_w2 != max_w:
|
||||
canvas2 = np.ones((new_h, max_w, 3), dtype=np.uint8) * 255
|
||||
offset = (max_w - new_w2) // 2
|
||||
canvas2[:, offset:offset+new_w2] = img2_resized
|
||||
img2_resized = canvas2
|
||||
|
||||
return img1_resized, img2_resized
|
||||
|
||||
def _resize_to_match(self, img1, img2):
|
||||
"""调整图像尺寸匹配(保留原方法作为备用)"""
|
||||
h2, w2 = img2.shape[:2]
|
||||
return cv2.resize(img1, (w2, h2))
|
||||
|
||||
def _find_diff_regions(self, diff_image):
|
||||
"""查找差异区域"""
|
||||
threshold = self.config['diff_threshold']
|
||||
min_area = self.config['min_diff_area']
|
||||
|
||||
# 二值化差异图像
|
||||
_, binary = cv2.threshold(diff_image, threshold, 255, cv2.THRESH_BINARY)
|
||||
|
||||
# 查找轮廓
|
||||
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
regions = []
|
||||
for contour in contours:
|
||||
area = cv2.contourArea(contour)
|
||||
if area > min_area: # 过滤小差异
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
regions.append({
|
||||
'x': x, 'y': y, 'width': w, 'height': h,
|
||||
'area': area,
|
||||
'center_x': x + w//2, 'center_y': y + h//2
|
||||
})
|
||||
|
||||
return regions
|
||||
|
||||
def _generate_comparison_image(self, img1, img2, diff_img, regions, score, output_path):
|
||||
"""生成比对结果图像"""
|
||||
# 创建拼接图像
|
||||
h, w = img1.shape[:2]
|
||||
result_img = np.zeros((h, w*3, 3), dtype=np.uint8)
|
||||
|
||||
# 放置原图像
|
||||
result_img[:, :w] = img1
|
||||
result_img[:, w:2*w] = img2
|
||||
result_img[:, 2*w:] = cv2.cvtColor(diff_img, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
# 标记差异区域
|
||||
for region in regions:
|
||||
x, y, rw, rh = region['x'], region['y'], region['width'], region['height']
|
||||
# 在截图上标记
|
||||
cv2.rectangle(result_img, (x, y), (x+rw, y+rh), (0, 0, 255), 2)
|
||||
# 在设计图上标记
|
||||
cv2.rectangle(result_img, (w+x, y), (w+x+rw, y+rh), (0, 0, 255), 2)
|
||||
|
||||
# 添加文本信息
|
||||
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||
|
||||
# 相似度信息
|
||||
similarity_text = f"Similarity: {score:.2%}"
|
||||
cv2.putText(result_img, similarity_text, (10, 30), font, 1, (255, 255, 255), 2)
|
||||
|
||||
# 差异统计
|
||||
diff_count = len(regions)
|
||||
diff_text = f"Differences: {diff_count}"
|
||||
cv2.putText(result_img, diff_text, (10, 70), font, 0.8, (255, 255, 255), 2)
|
||||
|
||||
# 标签
|
||||
cv2.putText(result_img, "Screenshot", (10, h-10), font, 0.7, (255, 255, 255), 2)
|
||||
cv2.putText(result_img, "Design", (w+10, h-10), font, 0.7, (255, 255, 255), 2)
|
||||
cv2.putText(result_img, "Difference", (2*w+10, h-10), font, 0.7, (255, 255, 255), 2)
|
||||
|
||||
# 保存结果
|
||||
cv2.imwrite(str(output_path), result_img)
|
||||
print(f"📊 比对结果已保存: {output_path}")
|
||||
|
||||
def get_comparison_summary(self):
|
||||
"""获取比对结果摘要"""
|
||||
if not self.comparison_results:
|
||||
return None
|
||||
|
||||
latest_result = self.comparison_results[-1]
|
||||
|
||||
summary = {
|
||||
'total_comparisons': len(self.comparison_results),
|
||||
'latest_similarity': latest_result['similarity_score'],
|
||||
'latest_diff_count': len(latest_result['diff_regions']),
|
||||
'meets_threshold': latest_result['meets_threshold'],
|
||||
'average_similarity': sum(r['similarity_score'] for r in self.comparison_results) / len(self.comparison_results)
|
||||
}
|
||||
|
||||
return summary
|
||||
|
||||
def find_design_files(self, design_dir):
|
||||
"""查找设计目录中的所有支持格式文件"""
|
||||
design_dir = Path(design_dir)
|
||||
supported_formats = self.config['supported_formats']
|
||||
|
||||
design_files = []
|
||||
for format_ext in supported_formats:
|
||||
design_files.extend(design_dir.glob(f"*{format_ext}"))
|
||||
|
||||
# 优先返回SVG文件
|
||||
svg_files = [f for f in design_files if f.suffix.lower() == '.svg']
|
||||
other_files = [f for f in design_files if f.suffix.lower() != '.svg']
|
||||
|
||||
return svg_files + other_files
|
||||
|
||||
def batch_compare(self, screenshot_path, design_dir, output_dir=None):
|
||||
"""批量比对截图与设计目录中的所有文件"""
|
||||
design_files = self.find_design_files(design_dir)
|
||||
|
||||
if not design_files:
|
||||
print(f"❌ 在 {design_dir} 中未找到支持的设计文件")
|
||||
return []
|
||||
|
||||
results = []
|
||||
for design_file in design_files:
|
||||
print(f"🔍 比对设计文件: {design_file.name}")
|
||||
|
||||
output_path = None
|
||||
if output_dir:
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
output_path = output_dir / f"comparison_{design_file.stem}.png"
|
||||
|
||||
result = self.compare_images(screenshot_path, design_file, output_path)
|
||||
if result:
|
||||
result['design_file'] = str(design_file)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
BIN
visual_comparisons/comparison_20251031_174425.png
Normal file
BIN
visual_comparisons/comparison_20251031_174425.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
658
xml_analyzer.py
Normal file
658
xml_analyzer.py
Normal file
@@ -0,0 +1,658 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
XML布局分析器模块
|
||||
用于解析和分析Android UI XML布局文件,以及SVG设计图的布局结构
|
||||
"""
|
||||
|
||||
from lxml import etree
|
||||
import re
|
||||
from pathlib import Path
|
||||
from config import Config
|
||||
|
||||
|
||||
class XMLAnalyzer:
|
||||
"""XML布局分析器"""
|
||||
|
||||
def __init__(self):
|
||||
self.elements_data = []
|
||||
self.issues = []
|
||||
self.svg_elements = []
|
||||
self.layout_comparison_result = None
|
||||
self.config = Config.XML_ANALYSIS
|
||||
|
||||
def parse_xml(self, xml_content):
|
||||
"""解析XML内容"""
|
||||
try:
|
||||
# 如果xml_content是字符串且包含编码声明,需要特殊处理
|
||||
if isinstance(xml_content, str):
|
||||
# 移除XML声明,因为etree.fromstring不支持带编码声明的Unicode字符串
|
||||
if xml_content.strip().startswith('<?xml'):
|
||||
# 找到XML声明的结束位置
|
||||
declaration_end = xml_content.find('?>') + 2
|
||||
xml_content = xml_content[declaration_end:].strip()
|
||||
|
||||
root = etree.fromstring(xml_content)
|
||||
self.elements_data = []
|
||||
self.issues = []
|
||||
self._parse_element(root, 0)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ XML解析失败: {e}")
|
||||
return False
|
||||
|
||||
def _parse_element(self, element, depth):
|
||||
"""递归解析XML元素"""
|
||||
# 提取元素信息
|
||||
element_info = {
|
||||
'tag': element.tag,
|
||||
'depth': depth,
|
||||
'attributes': dict(element.attrib),
|
||||
'text': element.text.strip() if element.text else '',
|
||||
'children_count': len(element)
|
||||
}
|
||||
|
||||
# 解析bounds属性
|
||||
bounds = element.get('bounds', '')
|
||||
if bounds:
|
||||
try:
|
||||
# bounds格式: [x1,y1][x2,y2]
|
||||
coords = bounds.replace('[', '').replace(']', ',').split(',')
|
||||
if len(coords) >= 4:
|
||||
x1, y1, x2, y2 = map(int, coords[:4])
|
||||
element_info['bounds'] = {
|
||||
'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2,
|
||||
'width': x2 - x1, 'height': y2 - y1,
|
||||
'center_x': (x1 + x2) // 2, 'center_y': (y1 + y2) // 2
|
||||
}
|
||||
except:
|
||||
pass
|
||||
|
||||
# 检查常见问题
|
||||
if self.config['check_accessibility'] or self.config['check_duplicates']:
|
||||
self._check_element_issues(element_info)
|
||||
|
||||
self.elements_data.append(element_info)
|
||||
|
||||
# 递归处理子元素
|
||||
for child in element:
|
||||
self._parse_element(child, depth + 1)
|
||||
|
||||
def _check_element_issues(self, element_info):
|
||||
"""检查元素问题"""
|
||||
issues = []
|
||||
|
||||
# 检查可访问性
|
||||
if self.config['check_accessibility']:
|
||||
if element_info.get('attributes', {}).get('clickable') == 'true':
|
||||
if not element_info.get('attributes', {}).get('content-desc'):
|
||||
issues.append("可点击元素缺少content-desc")
|
||||
|
||||
# 检查文本大小
|
||||
if 'bounds' in element_info:
|
||||
bounds = element_info['bounds']
|
||||
min_size = self.config['min_clickable_size']
|
||||
if bounds['width'] < min_size or bounds['height'] < min_size:
|
||||
if element_info.get('attributes', {}).get('clickable') == 'true':
|
||||
issues.append(f"可点击元素尺寸过小: {bounds['width']}x{bounds['height']}")
|
||||
|
||||
# 检查文本内容
|
||||
text = element_info.get('text', '')
|
||||
if text:
|
||||
max_length = self.config['max_text_length']
|
||||
if len(text) > max_length:
|
||||
issues.append(f"文本过长: {len(text)}字符")
|
||||
if text.lower() in ['click here', 'button', 'text']:
|
||||
issues.append(f"文本描述不明确: '{text}'")
|
||||
|
||||
# 检查重叠元素
|
||||
if 'bounds' in element_info:
|
||||
bounds = element_info['bounds']
|
||||
if bounds['width'] <= 0 or bounds['height'] <= 0:
|
||||
issues.append("元素尺寸无效")
|
||||
|
||||
if issues:
|
||||
self.issues.extend([{
|
||||
'element': element_info['tag'],
|
||||
'issue': issue,
|
||||
'bounds': element_info.get('bounds', {}),
|
||||
'attributes': element_info.get('attributes', {})
|
||||
} for issue in issues])
|
||||
|
||||
def get_statistics(self):
|
||||
"""获取统计信息"""
|
||||
stats = {
|
||||
'total_elements': len(self.elements_data),
|
||||
'clickable_elements': 0,
|
||||
'text_elements': 0,
|
||||
'image_elements': 0,
|
||||
'max_depth': 0,
|
||||
'issues_count': len(self.issues)
|
||||
}
|
||||
|
||||
for element in self.elements_data:
|
||||
if element.get('attributes', {}).get('clickable') == 'true':
|
||||
stats['clickable_elements'] += 1
|
||||
if element.get('text'):
|
||||
stats['text_elements'] += 1
|
||||
if 'Image' in element.get('tag', ''):
|
||||
stats['image_elements'] += 1
|
||||
stats['max_depth'] = max(stats['max_depth'], element.get('depth', 0))
|
||||
|
||||
return stats
|
||||
|
||||
def find_duplicate_ids(self):
|
||||
"""查找重复的resource-id"""
|
||||
if not self.config['check_duplicates']:
|
||||
return []
|
||||
|
||||
id_counts = {}
|
||||
duplicates = []
|
||||
|
||||
for element in self.elements_data:
|
||||
resource_id = element.get('attributes', {}).get('resource-id')
|
||||
if resource_id:
|
||||
id_counts[resource_id] = id_counts.get(resource_id, 0) + 1
|
||||
|
||||
for resource_id, count in id_counts.items():
|
||||
if count > 1:
|
||||
duplicates.append({
|
||||
'resource_id': resource_id,
|
||||
'count': count
|
||||
})
|
||||
|
||||
return duplicates
|
||||
|
||||
def get_accessibility_issues(self):
|
||||
"""获取可访问性问题"""
|
||||
accessibility_issues = []
|
||||
|
||||
for issue in self.issues:
|
||||
if 'content-desc' in issue['issue'] or '可访问性' in issue['issue']:
|
||||
accessibility_issues.append(issue)
|
||||
|
||||
return accessibility_issues
|
||||
|
||||
def export_elements_data(self, format='json'):
|
||||
"""导出元素数据"""
|
||||
if format == 'json':
|
||||
import json
|
||||
return json.dumps(self.elements_data, ensure_ascii=False, indent=2)
|
||||
elif format == 'csv':
|
||||
import csv
|
||||
import io
|
||||
output = io.StringIO()
|
||||
if self.elements_data:
|
||||
fieldnames = ['tag', 'depth', 'text', 'clickable', 'resource-id']
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for element in self.elements_data:
|
||||
row = {
|
||||
'tag': element.get('tag', ''),
|
||||
'depth': element.get('depth', 0),
|
||||
'text': element.get('text', ''),
|
||||
'clickable': element.get('attributes', {}).get('clickable', 'false'),
|
||||
'resource-id': element.get('attributes', {}).get('resource-id', '')
|
||||
}
|
||||
writer.writerow(row)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
return str(self.elements_data)
|
||||
|
||||
def parse_svg(self, svg_path):
|
||||
"""解析SVG文件,提取布局元素信息"""
|
||||
try:
|
||||
svg_path = Path(svg_path)
|
||||
if not svg_path.exists():
|
||||
print(f"❌ SVG文件不存在: {svg_path}")
|
||||
return False
|
||||
|
||||
with open(svg_path, 'r', encoding='utf-8') as f:
|
||||
svg_content = f.read()
|
||||
|
||||
# 解析SVG XML
|
||||
root = etree.fromstring(svg_content.encode('utf-8'))
|
||||
|
||||
# 获取SVG的命名空间
|
||||
namespaces = {'svg': 'http://www.w3.org/2000/svg'}
|
||||
if root.nsmap:
|
||||
namespaces.update(root.nsmap)
|
||||
|
||||
self.svg_elements = []
|
||||
self._parse_svg_element(root, 0, namespaces)
|
||||
|
||||
print(f"✅ SVG解析完成,找到 {len(self.svg_elements)} 个元素")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ SVG解析失败: {e}")
|
||||
return False
|
||||
|
||||
def _parse_svg_element(self, element, depth, namespaces):
|
||||
"""递归解析SVG元素"""
|
||||
# 获取元素标签名(去除命名空间前缀)
|
||||
tag = element.tag
|
||||
if '}' in tag:
|
||||
tag = tag.split('}')[1]
|
||||
|
||||
# 提取元素信息
|
||||
element_info = {
|
||||
'tag': tag,
|
||||
'depth': depth,
|
||||
'attributes': dict(element.attrib),
|
||||
'text': (element.text or '').strip(),
|
||||
'children_count': len(element)
|
||||
}
|
||||
|
||||
# 解析位置和尺寸信息
|
||||
self._extract_svg_geometry(element_info)
|
||||
|
||||
# 解析样式信息
|
||||
self._extract_svg_styles(element_info)
|
||||
|
||||
# 识别UI元素类型
|
||||
self._classify_svg_element(element_info)
|
||||
|
||||
self.svg_elements.append(element_info)
|
||||
|
||||
# 递归处理子元素
|
||||
for child in element:
|
||||
self._parse_svg_element(child, depth + 1, namespaces)
|
||||
|
||||
def _extract_svg_geometry(self, element_info):
|
||||
"""提取SVG元素的几何信息"""
|
||||
attrs = element_info['attributes']
|
||||
tag = element_info['tag']
|
||||
|
||||
# 初始化几何信息
|
||||
geometry = {
|
||||
'x': 0, 'y': 0, 'width': 0, 'height': 0,
|
||||
'center_x': 0, 'center_y': 0
|
||||
}
|
||||
|
||||
try:
|
||||
if tag == 'rect':
|
||||
geometry['x'] = float(attrs.get('x', 0))
|
||||
geometry['y'] = float(attrs.get('y', 0))
|
||||
geometry['width'] = float(attrs.get('width', 0))
|
||||
geometry['height'] = float(attrs.get('height', 0))
|
||||
|
||||
elif tag == 'circle':
|
||||
cx = float(attrs.get('cx', 0))
|
||||
cy = float(attrs.get('cy', 0))
|
||||
r = float(attrs.get('r', 0))
|
||||
geometry['x'] = cx - r
|
||||
geometry['y'] = cy - r
|
||||
geometry['width'] = r * 2
|
||||
geometry['height'] = r * 2
|
||||
|
||||
elif tag == 'ellipse':
|
||||
cx = float(attrs.get('cx', 0))
|
||||
cy = float(attrs.get('cy', 0))
|
||||
rx = float(attrs.get('rx', 0))
|
||||
ry = float(attrs.get('ry', 0))
|
||||
geometry['x'] = cx - rx
|
||||
geometry['y'] = cy - ry
|
||||
geometry['width'] = rx * 2
|
||||
geometry['height'] = ry * 2
|
||||
|
||||
elif tag == 'line':
|
||||
x1 = float(attrs.get('x1', 0))
|
||||
y1 = float(attrs.get('y1', 0))
|
||||
x2 = float(attrs.get('x2', 0))
|
||||
y2 = float(attrs.get('y2', 0))
|
||||
geometry['x'] = min(x1, x2)
|
||||
geometry['y'] = min(y1, y2)
|
||||
geometry['width'] = abs(x2 - x1)
|
||||
geometry['height'] = abs(y2 - y1)
|
||||
|
||||
elif tag == 'text':
|
||||
geometry['x'] = float(attrs.get('x', 0))
|
||||
geometry['y'] = float(attrs.get('y', 0))
|
||||
# 文本的宽高需要根据字体大小估算
|
||||
font_size = self._extract_font_size(attrs)
|
||||
text_length = len(element_info.get('text', ''))
|
||||
geometry['width'] = text_length * font_size * 0.6 # 估算宽度
|
||||
geometry['height'] = font_size
|
||||
|
||||
elif tag == 'g': # 组元素
|
||||
# 对于组元素,尝试从transform属性获取位置
|
||||
transform = attrs.get('transform', '')
|
||||
translate_match = re.search(r'translate\(([^)]+)\)', transform)
|
||||
if translate_match:
|
||||
coords = translate_match.group(1).split(',')
|
||||
if len(coords) >= 2:
|
||||
geometry['x'] = float(coords[0].strip())
|
||||
geometry['y'] = float(coords[1].strip())
|
||||
|
||||
# 计算中心点
|
||||
geometry['center_x'] = geometry['x'] + geometry['width'] / 2
|
||||
geometry['center_y'] = geometry['y'] + geometry['height'] / 2
|
||||
|
||||
element_info['geometry'] = geometry
|
||||
|
||||
except (ValueError, TypeError):
|
||||
# 如果解析失败,使用默认值
|
||||
element_info['geometry'] = geometry
|
||||
|
||||
def _extract_font_size(self, attrs):
|
||||
"""提取字体大小"""
|
||||
# 从style属性中提取
|
||||
style = attrs.get('style', '')
|
||||
font_size_match = re.search(r'font-size:\s*(\d+(?:\.\d+)?)', style)
|
||||
if font_size_match:
|
||||
return float(font_size_match.group(1))
|
||||
|
||||
# 从font-size属性中提取
|
||||
font_size = attrs.get('font-size', '12')
|
||||
try:
|
||||
return float(re.sub(r'[^\d.]', '', font_size))
|
||||
except:
|
||||
return 12.0 # 默认字体大小
|
||||
|
||||
def _extract_svg_styles(self, element_info):
|
||||
"""提取SVG元素的样式信息"""
|
||||
attrs = element_info['attributes']
|
||||
styles = {}
|
||||
|
||||
# 解析style属性
|
||||
style_attr = attrs.get('style', '')
|
||||
if style_attr:
|
||||
for style_rule in style_attr.split(';'):
|
||||
if ':' in style_rule:
|
||||
key, value = style_rule.split(':', 1)
|
||||
styles[key.strip()] = value.strip()
|
||||
|
||||
# 直接的样式属性
|
||||
style_attrs = ['fill', 'stroke', 'stroke-width', 'opacity', 'font-family', 'font-size', 'color']
|
||||
for attr in style_attrs:
|
||||
if attr in attrs:
|
||||
styles[attr] = attrs[attr]
|
||||
|
||||
element_info['styles'] = styles
|
||||
|
||||
def _classify_svg_element(self, element_info):
|
||||
"""分类SVG元素,识别可能的UI组件类型"""
|
||||
tag = element_info['tag']
|
||||
attrs = element_info['attributes']
|
||||
styles = element_info.get('styles', {})
|
||||
text = element_info.get('text', '')
|
||||
|
||||
ui_type = 'unknown'
|
||||
|
||||
if tag == 'text' or text:
|
||||
ui_type = 'text'
|
||||
elif tag == 'rect':
|
||||
# 判断是否为按钮
|
||||
if styles.get('fill') and styles.get('stroke'):
|
||||
ui_type = 'button'
|
||||
else:
|
||||
ui_type = 'container'
|
||||
elif tag == 'circle' or tag == 'ellipse':
|
||||
ui_type = 'button' # 圆形通常是按钮
|
||||
elif tag == 'image':
|
||||
ui_type = 'image'
|
||||
elif tag == 'g':
|
||||
ui_type = 'group'
|
||||
elif tag == 'path':
|
||||
ui_type = 'icon' # path通常用于图标
|
||||
|
||||
element_info['ui_type'] = ui_type
|
||||
|
||||
def compare_layouts(self, svg_path):
|
||||
"""比较XML布局与SVG设计图的结构"""
|
||||
if not self.elements_data:
|
||||
print("❌ 请先解析XML布局")
|
||||
return None
|
||||
|
||||
if not self.parse_svg(svg_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
# 分析XML中的UI元素
|
||||
xml_ui_elements = self._extract_xml_ui_elements()
|
||||
|
||||
# 分析SVG中的UI元素
|
||||
svg_ui_elements = self._extract_svg_ui_elements()
|
||||
|
||||
# 进行布局比对
|
||||
comparison_result = {
|
||||
'xml_elements_count': len(xml_ui_elements),
|
||||
'svg_elements_count': len(svg_ui_elements),
|
||||
'matched_elements': [],
|
||||
'unmatched_xml': [],
|
||||
'unmatched_svg': [],
|
||||
'layout_similarity': 0.0,
|
||||
'position_differences': [],
|
||||
'size_differences': []
|
||||
}
|
||||
|
||||
# 匹配相似的元素
|
||||
self._match_ui_elements(xml_ui_elements, svg_ui_elements, comparison_result)
|
||||
|
||||
# 计算布局相似度
|
||||
self._calculate_layout_similarity(comparison_result)
|
||||
|
||||
self.layout_comparison_result = comparison_result
|
||||
|
||||
print(f"📊 布局比对完成:")
|
||||
print(f" XML元素: {comparison_result['xml_elements_count']}个")
|
||||
print(f" SVG元素: {comparison_result['svg_elements_count']}个")
|
||||
print(f" 匹配元素: {len(comparison_result['matched_elements'])}个")
|
||||
print(f" 布局相似度: {comparison_result['layout_similarity']:.1%}")
|
||||
|
||||
return comparison_result
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 布局比对失败: {e}")
|
||||
return None
|
||||
|
||||
def _extract_xml_ui_elements(self):
|
||||
"""从XML数据中提取UI元素"""
|
||||
ui_elements = []
|
||||
|
||||
for element in self.elements_data:
|
||||
# 过滤掉容器元素,只保留实际的UI组件
|
||||
if self._is_ui_component(element):
|
||||
ui_element = {
|
||||
'type': self._classify_xml_element(element),
|
||||
'text': element.get('text', ''),
|
||||
'bounds': element.get('bounds', {}),
|
||||
'attributes': element.get('attributes', {}),
|
||||
'source': 'xml'
|
||||
}
|
||||
ui_elements.append(ui_element)
|
||||
|
||||
return ui_elements
|
||||
|
||||
def _extract_svg_ui_elements(self):
|
||||
"""从SVG数据中提取UI元素"""
|
||||
ui_elements = []
|
||||
|
||||
for element in self.svg_elements:
|
||||
if element['ui_type'] != 'unknown':
|
||||
ui_element = {
|
||||
'type': element['ui_type'],
|
||||
'text': element.get('text', ''),
|
||||
'geometry': element.get('geometry', {}),
|
||||
'styles': element.get('styles', {}),
|
||||
'source': 'svg'
|
||||
}
|
||||
ui_elements.append(ui_element)
|
||||
|
||||
return ui_elements
|
||||
|
||||
def _is_ui_component(self, element):
|
||||
"""判断XML元素是否为UI组件"""
|
||||
tag = element.get('tag', '')
|
||||
attrs = element.get('attributes', {})
|
||||
|
||||
# 排除纯容器元素
|
||||
container_tags = ['LinearLayout', 'RelativeLayout', 'FrameLayout', 'ConstraintLayout']
|
||||
if any(container in tag for container in container_tags):
|
||||
return False
|
||||
|
||||
# 包含文本或可点击的元素
|
||||
if element.get('text') or attrs.get('clickable') == 'true':
|
||||
return True
|
||||
|
||||
# 特定的UI组件
|
||||
ui_tags = ['Button', 'TextView', 'ImageView', 'EditText', 'CheckBox', 'RadioButton']
|
||||
return any(ui_tag in tag for ui_tag in ui_tags)
|
||||
|
||||
def _classify_xml_element(self, element):
|
||||
"""分类XML元素"""
|
||||
tag = element.get('tag', '')
|
||||
attrs = element.get('attributes', {})
|
||||
|
||||
if 'Button' in tag:
|
||||
return 'button'
|
||||
elif 'TextView' in tag or element.get('text'):
|
||||
return 'text'
|
||||
elif 'ImageView' in tag:
|
||||
return 'image'
|
||||
elif 'EditText' in tag:
|
||||
return 'input'
|
||||
elif attrs.get('clickable') == 'true':
|
||||
return 'button'
|
||||
else:
|
||||
return 'container'
|
||||
|
||||
def _match_ui_elements(self, xml_elements, svg_elements, result):
|
||||
"""匹配XML和SVG中的UI元素"""
|
||||
matched_xml = set()
|
||||
matched_svg = set()
|
||||
|
||||
for i, xml_elem in enumerate(xml_elements):
|
||||
best_match = None
|
||||
best_score = 0
|
||||
|
||||
for j, svg_elem in enumerate(svg_elements):
|
||||
if j in matched_svg:
|
||||
continue
|
||||
|
||||
score = self._calculate_element_similarity(xml_elem, svg_elem)
|
||||
if score > best_score and score > 0.5: # 相似度阈值
|
||||
best_score = score
|
||||
best_match = j
|
||||
|
||||
if best_match is not None:
|
||||
matched_xml.add(i)
|
||||
matched_svg.add(best_match)
|
||||
|
||||
match_info = {
|
||||
'xml_element': xml_elements[i],
|
||||
'svg_element': svg_elements[best_match],
|
||||
'similarity': best_score,
|
||||
'position_diff': self._calculate_position_difference(
|
||||
xml_elements[i], svg_elements[best_match]
|
||||
),
|
||||
'size_diff': self._calculate_size_difference(
|
||||
xml_elements[i], svg_elements[best_match]
|
||||
)
|
||||
}
|
||||
result['matched_elements'].append(match_info)
|
||||
|
||||
# 记录未匹配的元素
|
||||
result['unmatched_xml'] = [xml_elements[i] for i in range(len(xml_elements)) if i not in matched_xml]
|
||||
result['unmatched_svg'] = [svg_elements[j] for j in range(len(svg_elements)) if j not in matched_svg]
|
||||
|
||||
def _calculate_element_similarity(self, xml_elem, svg_elem):
|
||||
"""计算两个元素的相似度"""
|
||||
score = 0
|
||||
|
||||
# 类型匹配
|
||||
if xml_elem['type'] == svg_elem['type']:
|
||||
score += 0.4
|
||||
|
||||
# 文本匹配
|
||||
xml_text = xml_elem.get('text', '').strip().lower()
|
||||
svg_text = svg_elem.get('text', '').strip().lower()
|
||||
|
||||
if xml_text and svg_text:
|
||||
if xml_text == svg_text:
|
||||
score += 0.4
|
||||
elif xml_text in svg_text or svg_text in xml_text:
|
||||
score += 0.2
|
||||
elif not xml_text and not svg_text:
|
||||
score += 0.2
|
||||
|
||||
# 位置相似度(相对位置)
|
||||
position_score = self._calculate_relative_position_similarity(xml_elem, svg_elem)
|
||||
score += position_score * 0.2
|
||||
|
||||
return min(score, 1.0)
|
||||
|
||||
def _calculate_relative_position_similarity(self, xml_elem, svg_elem):
|
||||
"""计算相对位置相似度"""
|
||||
# 这里简化处理,实际应该考虑屏幕尺寸的缩放
|
||||
return 0.5 # 暂时返回中等相似度
|
||||
|
||||
def _calculate_position_difference(self, xml_elem, svg_elem):
|
||||
"""计算位置差异"""
|
||||
xml_bounds = xml_elem.get('bounds', {})
|
||||
svg_geometry = svg_elem.get('geometry', {})
|
||||
|
||||
if not xml_bounds or not svg_geometry:
|
||||
return {'x': 0, 'y': 0}
|
||||
|
||||
return {
|
||||
'x': abs(xml_bounds.get('center_x', 0) - svg_geometry.get('center_x', 0)),
|
||||
'y': abs(xml_bounds.get('center_y', 0) - svg_geometry.get('center_y', 0))
|
||||
}
|
||||
|
||||
def _calculate_size_difference(self, xml_elem, svg_elem):
|
||||
"""计算尺寸差异"""
|
||||
xml_bounds = xml_elem.get('bounds', {})
|
||||
svg_geometry = svg_elem.get('geometry', {})
|
||||
|
||||
if not xml_bounds or not svg_geometry:
|
||||
return {'width': 0, 'height': 0}
|
||||
|
||||
return {
|
||||
'width': abs(xml_bounds.get('width', 0) - svg_geometry.get('width', 0)),
|
||||
'height': abs(xml_bounds.get('height', 0) - svg_geometry.get('height', 0))
|
||||
}
|
||||
|
||||
def _calculate_layout_similarity(self, result):
|
||||
"""计算整体布局相似度"""
|
||||
total_elements = max(result['xml_elements_count'], result['svg_elements_count'])
|
||||
if total_elements == 0:
|
||||
result['layout_similarity'] = 0.0
|
||||
return
|
||||
|
||||
matched_count = len(result['matched_elements'])
|
||||
base_similarity = matched_count / total_elements
|
||||
|
||||
# 考虑匹配质量
|
||||
if result['matched_elements']:
|
||||
avg_match_quality = sum(match['similarity'] for match in result['matched_elements']) / len(result['matched_elements'])
|
||||
result['layout_similarity'] = base_similarity * avg_match_quality
|
||||
else:
|
||||
result['layout_similarity'] = 0.0
|
||||
|
||||
def get_layout_comparison_summary(self):
|
||||
"""获取布局比对摘要"""
|
||||
if not self.layout_comparison_result:
|
||||
return "未进行布局比对"
|
||||
|
||||
result = self.layout_comparison_result
|
||||
summary = f"""
|
||||
📊 布局比对摘要:
|
||||
• XML元素数量: {result['xml_elements_count']}
|
||||
• SVG元素数量: {result['svg_elements_count']}
|
||||
• 匹配元素数量: {len(result['matched_elements'])}
|
||||
• 布局相似度: {result['layout_similarity']:.1%}
|
||||
• 未匹配XML元素: {len(result['unmatched_xml'])}
|
||||
• 未匹配SVG元素: {len(result['unmatched_svg'])}
|
||||
"""
|
||||
|
||||
if result['layout_similarity'] >= 0.8:
|
||||
summary += "\n✅ 布局高度一致"
|
||||
elif result['layout_similarity'] >= 0.6:
|
||||
summary += "\n⚠️ 布局基本一致,有少量差异"
|
||||
else:
|
||||
summary += "\n❌ 布局差异较大,建议检查"
|
||||
|
||||
return summary
|
||||
29
xml_layouts/layout_20251031_174424.xml
Normal file
29
xml_layouts/layout_20251031_174424.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
|
||||
<hierarchy index="0" class="hierarchy" rotation="0" width="1080" height="1920">
|
||||
|
||||
<android.widget.FrameLayout index="0" package="com.example.myapplication" class="android.widget.FrameLayout" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,0][1080,1920]" displayed="true" a11y-important="true" screen-reader-focusable="false" drawing-order="0" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
|
||||
|
||||
<android.widget.LinearLayout index="0" package="com.example.myapplication" class="android.widget.LinearLayout" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,0][1080,1920]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="1" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
|
||||
|
||||
<android.widget.FrameLayout index="0" package="com.example.myapplication" class="android.widget.FrameLayout" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,0][1080,1920]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="2" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
|
||||
|
||||
<android.widget.LinearLayout index="0" package="com.example.myapplication" class="android.widget.LinearLayout" text="" resource-id="com.example.myapplication:id/action_bar_root" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,0][1080,1920]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="1" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
|
||||
|
||||
<android.widget.FrameLayout index="0" package="com.example.myapplication" class="android.widget.FrameLayout" text="" resource-id="android:id/content" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,0][1080,1920]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="2" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
|
||||
|
||||
<android.view.ViewGroup index="0" package="com.example.myapplication" class="android.view.ViewGroup" text="" resource-id="com.example.myapplication:id/main" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,0][1080,1920]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="1" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
|
||||
|
||||
<android.view.ViewGroup index="0" package="com.example.myapplication" class="android.view.ViewGroup" text="" resource-id="com.example.myapplication:id/toolbar" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,72][1080,264]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="1" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
|
||||
|
||||
<android.widget.ImageButton index="0" package="com.example.myapplication" class="android.widget.ImageButton" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,84][168,252]" displayed="true" a11y-important="true" screen-reader-focusable="false" drawing-order="2" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false" />
|
||||
|
||||
<android.widget.TextView index="1" package="com.example.myapplication" class="android.widget.TextView" text="Title" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[476,124][604,212]" displayed="true" a11y-important="true" screen-reader-focusable="false" drawing-order="1" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false" />
|
||||
|
||||
<android.widget.ImageView index="2" package="com.example.myapplication" class="android.widget.ImageView" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[960,108][1080,228]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="3" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false" />
|
||||
|
||||
</android.view.ViewGroup>
|
||||
|
||||
<android.widget.ScrollView index="1" package="com.example.myapplication" class="android.widget.ScrollView" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" long-clickable="false" password="false" scrollable="true" selected="false" bounds="[0,264][1080,1920]" displayed="true" a11y-important="true" screen-reader-focusable="false" drawing-order="2" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
|
||||
|
||||
<android.view.ViewGroup index="0" package="com.example.myapplication" class="android.view.ViewGroup" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,264][1080,1920]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="1" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
|
||||
Reference in New Issue
Block a user