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