feat: 实现可调整布局系统

- 新增布局管理器支持拖拽调整面板宽度
- 新增状态管理器实现布局持久化
- 重构前端页面结构优化四栏式布局
- 新增 Jest 测试框架和完整测试用例
- 完善项目文档(PRD/FD/TDD/TODO/LOG)
- 清理 Python 缓存文件并更新 .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
胡绍康andClaude Opus 4.6 committed 2026-02-27 14:49:36 +08:00
1 parent 0eb36f5175
commit 0ab50db85f
65 files changed
+24948 -127

No files matched your search

+9
View File
@@ -26,6 +26,15 @@ data/
*.swp
*.swo
# Node / Jest
node_modules/
tests/layout-system/coverage/
# 本地临时文件/素材(避免误提交)
PixPin_*.png
exeample/
2026-02-26-api-key-credit-monitoring.md
# 日志
*.log
+78 -15
View File
@@ -35,12 +35,11 @@
- `app.py` - Flask 应用工厂,负责应用初始化和 Blueprint 注册
- `config.py` - 配置管理,环境变量读取
- `db.py` - 数据库连接管理,Schema 初始化和升级
- `legacy.py` - 旧版实现,正在迁移中 (约 5000 行)
- `audit.py` - 审计日志记录
- `errors.py` - 错误处理和 trace_id 生成
#### routes/ 路由层
Blueprint 模块化路由,每个文件对应一个功能模块:
Blueprint 模块化路由,每个文件对应一个功能模块 (共 11 个):
- `pages.py` - 页面路由 (首页/登录页)
- `accounts.py` - 邮箱账号 CRUD
@@ -54,8 +53,23 @@ Blueprint 模块化路由,每个文件对应一个功能模块:
- `system.py` - 系统操作 (导出/密码修改)
- `audit.py` - 审计日志查询
#### controllers/ 控制器层
请求处理和参数验证,每个文件对应一个路由模块 (共 11 个):
- `pages.py` - 页面控制器
- `accounts.py` - 账号管理控制器
- `groups.py` - 分组管理控制器
- `tags.py` - 标签控制器
- `emails.py` - 邮件操作控制器
- `temp_emails.py` - 临时邮箱控制器
- `oauth.py` - OAuth 控制器
- `settings.py` - 系统设置控制器
- `scheduler.py` - 调度器控制器
- `system.py` - 系统控制器
- `audit.py` - 审计控制器
#### services/ 服务层
业务逻辑实现,封装外部 API 调用:
业务逻辑实现,封装外部 API 调用 (共 9 个):
- `graph.py` - Microsoft Graph API 封装
- 获取邮件列表
@@ -72,6 +86,9 @@ Blueprint 模块化路由,每个文件对应一个功能模块:
- 定时刷新调度
- `email_delete.py` - 邮件删除服务
- 多 API 回退策略
- `scheduler.py` - 定时任务调度器
- APScheduler 封装
- 任务管理
- `gptmail.py` - GPTMail API 封装
- `http.py` - HTTP 客户端封装 (支持代理)
- `verification_extractor.py` - 验证码提取服务
@@ -80,7 +97,7 @@ Blueprint 模块化路由,每个文件对应一个功能模块:
- 正则表达式匹配
#### repositories/ 数据访问层
数据库 CRUD 操作封装:
数据库 CRUD 操作封装 (共 8 个):
- `accounts.py` - 邮箱账号数据访问
- 增删改查
@@ -94,8 +111,18 @@ Blueprint 模块化路由,每个文件对应一个功能模块:
- `refresh_runs.py` - 刷新运行记录数据访问
- `distributed_locks.py` - 分布式锁实现
#### middleware/ 中间件
横切关注点处理 (共 2 个):
- `trace.py` - trace_id 中间件
- 请求追踪 ID 生成
- 日志关联
- `error_handler.py` - 错误处理中间件
- 统一错误响应
- 异常捕获
#### security/ 安全模块
安全相关功能封装:
安全相关功能封装 (共 3 个):
- `auth.py` - 认证授权
- 登录验证
@@ -287,17 +314,53 @@ Repository 层封装数据访问,业务逻辑与数据库解耦。
- Blueprint 可独立部署
- 服务层可抽取为独立服务
## 迁移策略
## 前端架构
### 当前状态
- `legacy.py` 包含旧版实现
- 新架构逐步替换旧实现
- 保持 API 兼容性
### 静态资源结构
```
static/
├── css/
│ ├── main.css # 主样式
│ └── layout.css # 布局系统样式
└── js/
├── main.js # 主入口
├── layout-manager.js # 布局管理器
├── layout-bootstrap.js # 布局初始化
├── state-manager.js # 状态管理
└── features/ # 功能模块
├── accounts.js # 账号管理
├── emails.js # 邮件操作
├── groups.js # 分组管理
└── temp_emails.js # 临时邮箱
```
### 迁移步骤
### 模板结构
```
templates/
├── index.html # 主页面
├── login.html # 登录页
└── partials/ # 页面片段
├── modals.html # 模态框
└── scripts.html # 脚本引用
```
### 布局系统
- 四栏式可调整布局 (分组/账号/邮件列表/邮件详情)
- 拖拽调整栏宽,实时保存
- 支持折叠/展开
- 响应式设计,自适应窗口大小
## 架构演进
### 已完成的迁移
1. ✅ 创建新的分层结构
2. ✅ Blueprint 模块化路由
3. 🔄 逐步迁移业务逻辑到 Services
4. 🔄 数据访问迁移到 Repositories
5. ⏳ 移除 legacy.py
6. ⏳ 前后端完全分离
3. ✅ 业务逻辑迁移到 Services
4. ✅ 数据访问迁移到 Repositories
5. ✅ 移除 legacy.py
6. ✅ 中间件统一处理
### 下一步计划
- ⏳ 前后端完全分离 (RESTful API)
- ⏳ 前端框架引入 (Vue.js/React)
- ⏳ API 文档完善 (OpenAPI/Swagger)
+27 -11
View File
@@ -34,21 +34,29 @@ Web 应用 - Flask 后端 + 传统前端
```
outlookEmail/
├── web_outlook_app.py # 应用入口 (兼容层)
├── web_outlook_app.py # 应用入口
├── outlook_mail_reader.py # 命令行邮件读取工具
├── outlook_web/ # 主应用模块
│ ├── app.py # Flask 应用工厂
│ ├── config.py # 配置管理
│ ├── db.py # 数据库操作
│ ├── legacy.py # 旧版实现 (迁移中)
│ ├── audit.py # 审计日志
│ ├── errors.py # 错误处理
│ ├── routes/ # 路由层 (Blueprint)
│ ├── controllers/ # 控制器层
│ ├── services/ # 服务层
│ ├── repositories/ # 数据访问层
│ └── security/ # 安全模块
│ ├── security/ # 安全模块
│ └── middleware/ # 中间件
├── templates/ # HTML 模板
│ ├── index.html # 主页面
│ ├── login.html # 登录页
│ └── partials/ # 页面片段
├── static/ # 静态资源
│ ├── css/ # 样式文件
│ └── js/ # JavaScript 文件
│ ├── features/ # 功能模块
│ └── layout-*.js # 布局系统
├── tests/ # 测试
├── docs/ # 文档
└── data/ # 数据目录
@@ -100,7 +108,14 @@ outlookEmail/
- 连续错误自动停止
- 可配置轮询间隔/次数
### 8. 安全特性
### 8. 可调整布局系统
- 四栏式响应式布局
- 拖拽调整栏宽
- 布局状态持久化
- 支持折叠/展开
- 自适应窗口大小
### 9. 安全特性
- XSS 防护 (DOMPurify + iframe 沙箱)
- CSRF 防护 (Flask-WTF)
- 数据加密 (Fernet 对称加密)
@@ -137,10 +152,10 @@ Database (SQLite)
### 应用工厂模式
使用 `create_app()` 工厂函数创建 Flask 应用实例,便于测试和配置管理。
### 迁移中架构
- `legacy.py` 包含旧版实现 (约 20 万字节)
- 正在逐步迁移到新的分层架构
- 保持向后兼容性
### 完整分层架构
- 已完成从单体架构到分层架构的迁移
- Routes → Controllers → Services → Repositories 清晰分层
- 中间件统一处理横切关注点
## 数据模型
@@ -195,13 +210,14 @@ python web_outlook_app.py
- ✅ 验证码提取 (Graph → IMAP 回退)
- ✅ 自动轮询与通知
- ✅ 批量删除邮箱
- ✅ 架构迁移完成 (legacy.py 已移除)
- ✅ 可调整布局系统
### 进行中
- 🔄 架构迁移 (legacy.py → 分层架构)
- 🔄 前后端分离准备
- 🔄 单元测试覆盖率提升
### 待优化
- ⏳ 完全移除 legacy.py
- ⏳ 前后端完全分离
- ⏳ API 文档完善
- ⏳ 单元测试覆盖率提升
- ⏳ 性能优化与监控
+736
View File
@@ -0,0 +1,736 @@
# AI 执行提示词 - Outlook 邮件管理工具批量操作与自动化增强
## 项目背景
你正在为一个 Outlook 邮件管理工具添加三个核心功能:
1. **全选功能**:在分组面板的排序按钮右边添加全选复选框,支持一键选中当前分组下的所有邮箱账号,用于批量操作(移动分组、打标签)
2. **邮件信息快速复制功能**:在每个邮箱账号名称右边添加复制按钮,点击后自动提取最新邮件中的验证码和链接并复制到剪贴板
3. **自动轮询与通知功能**:支持定时轮询选中邮箱的新邮件,检测到新邮件时显示页面通知和视觉提示(红点)
## 核心技术要求
### 验证码提取算法
- **智能识别**:基于关键词("验证码"、"code"、"OTP"等)搜索上下文(前后50字符),提取4-8位数字/字母组合
- **保底提取**:使用正则表达式提取所有4-8位数字/字母组合,过滤日期(1900-2100)和时间(00:00-23:59)格式
- **链接提取**:使用正则提取所有HTTP/HTTPS链接并去重
- **输出格式**:`验证码 链接1 链接2`(空格分隔)
### 性能指标
- 全选1000个邮箱:< 100ms
- 验证码提取API响应:< 2秒
- 轮询不影响页面交互
### 轮询机制
- 客户端定时器(setInterval)
- 默认配置:10秒间隔,5次轮询
- 静默轮询,仅在检测到新邮件时显示通知
- 切换邮箱或达到次数后自动停止
## 参考文档
在开始实现前,请仔细阅读以下文档:
1. **PRD(产品需求文档)**:`docs/PRD/Outlook邮件管理工具-批量操作与自动化增强PRD.md`
- 了解业务目标、用户故事、功能需求
2. **FD(功能设计文档)**:`docs/FD/Outlook邮件管理工具-批量操作与自动化增强FD.md`
- 了解需要实现的功能清单(27项功能验收 + 5项非功能验收 + 5项回归验收)
3. **TDD(技术设计文档)**:`docs/TDD/Outlook邮件管理工具-批量操作与自动化增强TDD.md`
- 了解详细的技术实现方案、算法设计、API规范、数据结构
4. **TODO(任务清单)**:`docs/TODO/Outlook邮件管理工具-批量操作与自动化增强TODO.md`
- 这是你的主要执行指南,包含详细的任务分解和验收标准
5. **测试文档**:`docs/TEST/` 目录下的测试文档
- 了解测试用例和验收标准
6. **测试代码**:`tests/` 目录下的测试文件
- 参考已准备好的测试用例结构
## 执行计划
请严格按照以下阶段顺序执行,每个阶段完成后进行自测:
### 阶段 0:准备工作(必须先完成)
```bash
# 1. 确认现有测试通过
python -m unittest discover -s tests -v
# 2. 准备测试邮件样本
# 创建至少10封测试邮件,覆盖不同验证码格式(参考测试文档中的15个邮件样本)
```
**验收标准**:
- [ ] 所有现有单元测试通过
- [ ] 测试邮件样本已准备(至少10封)
- [ ] 已仔细阅读所有参考文档
---
### 阶段 1:后端基础(2-3天)
**目标**:实现验证码提取服务和API接口
#### 1.1 创建验证码提取服务
**文件**:`outlook_web/services/verification_extractor.py`
**核心函数**:
```python
def smart_extract_verification_code(email_content: str) -> Optional[str]:
"""智能识别验证码(基于关键词)"""
pass
def fallback_extract_verification_code(email_content: str) -> Optional[str]:
"""保底提取验证码(正则 + 过滤)"""
pass
def extract_links(email_content: str) -> List[str]:
"""提取所有HTTP/HTTPS链接"""
pass
def extract_email_text(email: dict) -> str:
"""从邮件对象提取纯文本(HTML转文本)"""
pass
def extract_verification_info(email: dict) -> dict:
"""完整提取流程(验证码 + 链接 + 格式化)"""
pass
```
**实现要点**:
- 关键词列表:`["验证码", "code", "验证", "verification", "OTP", "动态码", "校验码", "verify code"]`
- 验证码正则:`r'\b[A-Z0-9]{4,8}\b'`
- 链接正则:`r'https?://[^\s<>"{}|\\^`\[\]]+'`
- 日期过滤:`1900 <= year <= 2100`
- 时间过滤:`00:00 <= time <= 23:59`
#### 1.2 创建API接口
**文件**:`outlook_web/routes/emails.py` 和 `outlook_web/legacy.py`
**路由**:`GET /api/emails/<email>/extract-verification`
**响应格式**:
```json
{
"success": true,
"data": {
"verification_code": "123456",
"links": ["https://example.com/verify"],
"formatted": "123456 https://example.com/verify"
},
"trace_id": "uuid"
}
```
**错误处理**:
- 邮箱不存在 → 404
- 未找到邮件 → 404
- 未找到验证信息 → 404
- Token过期 → 401
- 服务器错误 → 500
#### 1.3 编写单元测试
**文件**:`tests/test_verification_extractor.py`(已创建,需要实现TODO部分)
**测试覆盖**:
- 智能识别:中文关键词、英文关键词、OTP关键词
- 保底提取:纯数字、数字+字母、过滤日期/时间
- 链接提取:单个、多个、去重
- 边界情况:空邮件、未找到、HTML转文本
**验收标准**:
- [ ] 所有单元测试通过(18个测试用例)
- [ ] 验证码提取成功率 > 90%(基于测试邮件样本)
- [ ] API响应时间 < 2秒
---
### 阶段 2:后端配置(1天)
**目标**:添加轮询配置到数据库和设置API
#### 2.1 数据库变更
**文件**:`outlook_web/db.py`
**修改位置**:`init_db()` 函数
**添加配置**:
```python
# 在 init_db() 函数中添加
cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
("enable_auto_polling", "false"))
cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
("polling_interval", "10"))
cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
("polling_count", "5"))
```
#### 2.2 更新设置API
**文件**:`outlook_web/legacy.py`
**修改接口**:
- `GET /api/settings`:添加返回 `enable_auto_polling`、`polling_interval`、`polling_count`
- `PUT /api/settings`:添加处理这三个字段,验证范围(interval: 5-300, count: 1-100)
**验收标准**:
- [ ] 数据库初始化时自动创建配置项
- [ ] GET /api/settings 返回轮询配置
- [ ] PUT /api/settings 可以更新轮询配置
- [ ] 参数验证正确(超出范围返回400错误)
---
### 阶段 3:前端基础(2-3天)
**目标**:实现全选功能和复制按钮
#### 3.1 全选功能
**文件**:`static/js/features/accounts.js` 或 `static/js/main.js`
**核心函数**:
```javascript
// 全选状态枚举
const SelectAllState = {
NONE: 'none', // 未选中
PARTIAL: 'partial', // 部分选中
ALL: 'all' // 全选
};
// 全选/取消全选
function toggleSelectAll() {
const state = getSelectAllState();
if (state === SelectAllState.ALL) {
unselectAllAccounts();
} else {
selectAllAccounts();
}
}
// 选中所有邮箱
function selectAllAccounts() {
// 使用 Set 数据结构(O(1)查找)
// 使用 requestAnimationFrame 优化渲染
}
// 更新全选复选框状态
function updateSelectAllCheckbox() {
const checkbox = document.getElementById('selectAllAccounts');
const state = getSelectAllState();
checkbox.checked = (state === SelectAllState.ALL);
checkbox.indeterminate = (state === SelectAllState.PARTIAL);
}
```
**HTML结构**(在排序按钮右边添加):
```html
<label class="select-all-container">
<input type="checkbox" id="selectAllAccounts" />
<span>全选</span>
</label>
```
**CSS样式**:
```css
.select-all-container {
display: inline-flex;
align-items: center;
margin-left: 10px;
}
input[type="checkbox"]:indeterminate {
/* 半选状态样式 */
}
```
#### 3.2 复制按钮
**文件**:`static/js/features/accounts.js`
**核心函数**:
```javascript
async function copyVerificationInfo(email) {
const btn = event.target;
// 1. 设置加载状态
btn.disabled = true;
btn.classList.add('loading');
try {
// 2. 调用API提取验证信息
const response = await fetch(`/api/emails/${email}/extract-verification`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '提取失败');
}
// 3. 复制到剪贴板
await copyToClipboard(data.data.formatted);
// 4. 显示成功状态
showNotification('已复制');
btn.classList.add('success');
setTimeout(() => btn.classList.remove('success'), 1000);
} catch (error) {
// 5. 处理错误
if (error.message.includes('未找到')) {
showNotification('未找到验证信息', 'error');
} else {
showNotification('复制失败', 'error');
}
} finally {
// 6. 恢复按钮状态
btn.disabled = false;
btn.classList.remove('loading');
}
}
async function copyToClipboard(text) {
// 优先使用 Clipboard API
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
} else {
// 降级方案:execCommand
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
}
```
**HTML结构**(在账号名称右边添加):
```html
<button class="copy-verification-btn" onclick="copyVerificationInfo('${account.email}')">
<i class="icon-copy"></i>
</button>
```
**验收标准**:
- [ ] 全选复选框显示在排序按钮右边
- [ ] 点击全选可以选中/取消选中所有邮箱
- [ ] 半选状态正确显示
- [ ] 切换分组时全选状态重置
- [ ] 复制按钮显示在账号名称右边
- [ ] 点击复制按钮成功复制验证码和链接
- [ ] 显示"已复制"通知
- [ ] 按钮有加载/成功/失败状态的视觉反馈
- [ ] 全选1000个邮箱耗时 < 100ms
---
### 阶段 4:前端轮询与通知(2-3天)
**目标**:实现自动轮询和新邮件通知
#### 4.1 设置界面
**文件**:`templates/index.html` 或设置模态框
**HTML结构**:
```html
<div class="setting-item">
<label>
<input type="checkbox" id="enableAutoPolling" />
启用自动轮询
</label>
</div>
<div class="setting-item">
<label>轮询间隔(秒):</label>
<input type="number" id="pollingInterval" min="5" max="300" value="10" />
<span class="hint">建议:10-30秒</span>
</div>
<div class="setting-item">
<label>轮询次数:</label>
<input type="number" id="pollingCount" min="1" max="100" value="5" />
<span class="hint">建议:3-10次</span>
</div>
```
#### 4.2 轮询逻辑
**文件**:`static/js/features/polling.js` 或 `static/js/main.js`
**核心函数**:
```javascript
// 全局变量
let pollingTimer = null;
let pollingCount = 0;
let pollingMaxCount = 5;
let pollingInterval = 10000; // 毫秒
let isPollingEnabled = false;
let lastEmailIds = new Set();
// 启动轮询
function startPolling(email) {
if (!isPollingEnabled) return;
stopPolling(); // 先停止之前的轮询
pollingCount = 0;
lastEmailIds = new Set(getCurrentEmails().map(e => e.id));
console.log(`开始轮询邮箱: ${email}, 间隔: ${pollingInterval}ms, 次数: ${pollingMaxCount}`);
pollingTimer = setInterval(() => {
pollForNewEmails(email);
}, pollingInterval);
}
// 停止轮询
function stopPolling() {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
console.log('停止轮询');
}
}
// 轮询检查新邮件
async function pollForNewEmails(email) {
pollingCount++;
console.log(`轮询第 ${pollingCount}/${pollingMaxCount} 次`);
try {
// 1. 获取最新邮件列表
const response = await fetch(`/api/emails/${email}`);
const data = await response.json();
if (!response.ok) {
throw new Error('获取邮件失败');
}
// 2. 检测新邮件
const newEmails = detectNewEmails(lastEmailIds, data.emails);
// 3. 如果有新邮件,显示通知
if (newEmails.length > 0) {
showNewEmailNotification(email, newEmails);
showNewEmailBadge(email);
}
// 4. 更新邮件ID集合
lastEmailIds = new Set(data.emails.map(e => e.id));
// 5. 达到次数后停止
if (pollingCount >= pollingMaxCount) {
stopPolling();
console.log('轮询已完成');
}
} catch (error) {
console.error('轮询失败:', error);
stopPolling();
showNotification('轮询失败,已自动停止', 'error');
}
}
// 检测新邮件
function detectNewEmails(oldEmailIds, newEmails) {
return newEmails.filter(email => !oldEmailIds.has(email.id));
}
```
#### 4.3 通知组件
**文件**:`static/js/features/notification.js`
**核心函数**:
```javascript
function showNewEmailNotification(email, newEmails) {
const notification = document.createElement('div');
notification.className = 'email-notification';
notification.innerHTML = `
<div class="notification-header">
<span>${email}</span>
<button class="close-btn" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
<div class="notification-body">
收到新邮件:${newEmails[0].subject}
</div>
`;
document.body.appendChild(notification);
// 5秒后自动消失
setTimeout(() => {
notification.remove();
}, 5000);
}
function showNewEmailBadge(email) {
const accountItem = document.querySelector(`[data-email="${email}"]`);
if (accountItem) {
const badge = document.createElement('span');
badge.className = 'new-email-badge';
accountItem.appendChild(badge);
}
}
function hideNewEmailBadge(email) {
const badge = document.querySelector(`[data-email="${email}"] .new-email-badge`);
if (badge) {
badge.remove();
}
}
```
**CSS样式**:
```css
.email-notification {
position: fixed;
top: 20px;
right: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 16px;
min-width: 300px;
animation: slideIn 0.3s ease-out;
z-index: 9999;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.new-email-badge {
display: inline-block;
width: 8px;
height: 8px;
background: #ff4444;
border-radius: 50%;
margin-left: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
```
**集成到现有代码**:
```javascript
// 在 selectAccount() 函数中添加
function selectAccount(email) {
// ... 现有代码 ...
// 启动轮询
startPolling(email);
// 清除红点
hideNewEmailBadge(email);
}
// 在 loadAccountsByGroup() 函数中添加
function loadAccountsByGroup(groupId) {
// 停止轮询
stopPolling();
// ... 现有代码 ...
}
// 在页面卸载时停止轮询
window.addEventListener('beforeunload', () => {
stopPolling();
});
```
**验收标准**:
- [ ] 设置界面显示轮询配置项
- [ ] 启用轮询后选中邮箱时自动开始轮询
- [ ] 轮询到达次数后自动停止
- [ ] 检测到新邮件时显示通知
- [ ] 邮箱列表显示红点提示
- [ ] 轮询过程不影响页面操作
- [ ] 切换邮箱时停止当前轮询
- [ ] 通知5秒后自动消失
---
### 阶段 5:优化与完善(1-2天)
**目标**:性能优化、用户体验优化、测试完善
#### 5.1 性能优化
**全选性能优化**:
```javascript
function selectAllAccounts() {
const accounts = Array.from(document.querySelectorAll('.account-item'));
// 使用 DocumentFragment 减少重排重绘
requestAnimationFrame(() => {
accounts.forEach(account => {
const checkbox = account.querySelector('.account-checkbox');
checkbox.checked = true;
selectedAccounts.add(account.dataset.email);
});
updateBatchActionBar();
updateSelectAllCheckbox();
});
}
```
**验证码提取性能优化**:
- 优化正则表达式(避免回溯)
- 缓存邮件内容(避免重复提取)
**轮询性能优化**:
- 使用 `async/await` 避免阻塞
- 使用 `AbortController` 支持取消请求
#### 5.2 用户体验优化
- 复制按钮添加 tooltip 提示
- 通知组件支持多条通知队列
- 设置界面添加配置说明和建议值
#### 5.3 测试完善
**运行所有测试**:
```bash
# 单元测试
python -m unittest discover -s tests -v
# 手动测试(参考 tests/test_frontend_manual.py)
# 在浏览器中执行测试用例
```
**验收标准**:
- [ ] 所有单元测试通过(18个)
- [ ] 所有API测试通过(9个)
- [ ] 所有前端测试通过(21个)
- [ ] 所有性能测试通过(3个)
- [ ] 所有回归测试通过(5个)
---
## 最终验收清单(必须全部通过)
### 功能验收(27项)
#### 全选功能(7项)
- [ ] 全选复选框显示在排序按钮右边
- [ ] 点击全选复选框可以选中/取消选中当前分组下的所有邮箱
- [ ] 全选状态与邮箱列表选中状态实时同步(全选/半选/未选)
- [ ] 全选后可以执行批量移动分组操作
- [ ] 全选后可以执行批量打标签/去标签操作
- [ ] 切换分组时全选状态重置
- [ ] 搜索或筛选后,全选只影响当前显示的邮箱
#### 复制功能(9项)
- [ ] 复制按钮显示在邮箱名称右边
- [ ] 点击复制按钮可以自动提取验证码和链接
- [ ] 验证码提取成功率 > 90%(基于测试邮件样本)
- [ ] 链接提取能正确提取所有 HTTP/HTTPS 链接
- [ ] 复制成功后显示"已复制"通知
- [ ] 未找到验证信息时显示"未找到验证信息"提示
- [ ] 邮箱无数据时自动触发获取邮件操作
- [ ] 复制按钮有加载状态、成功状态、失败状态的视觉反馈
- [ ] 后端 API `/api/emails/<email>/extract-verification` 正常工作
#### 轮询功能(11项)
- [ ] 设置中心有"自动轮询"配置项(开关、间隔、次数)
- [ ] 启用轮询后,选中邮箱时自动开始轮询
- [ ] 轮询到达设置次数后自动停止
- [ ] 检测到新邮件时显示页面通知(邮箱地址 + 邮件主题)
- [ ] 邮箱列表显示新邮件视觉提示(红点/角标)
- [ ] 轮询过程静默进行,不干扰用户操作
- [ ] 轮询失败时停止并显示错误提示
- [ ] 切换邮箱时停止当前轮询
- [ ] 设置保存后下次选中邮箱时生效
- [ ] 数据库 settings 表包含轮询配置项
- [ ] 通知 5 秒后自动消失或点击关闭
### 非功能验收(5项)
- [ ] 全选操作在 100ms 内完成(1000 个邮箱)
- [ ] 验证码提取 API 响应时间 < 2 秒
- [ ] 轮询不影响页面正常使用
- [ ] 所有操作都有明确的成功/失败反馈
- [ ] 错误提示清晰易懂
### 回归验收(5项)
- [ ] 现有批量操作功能正常(移动分组、打标签)
- [ ] 现有邮件获取功能正常
- [ ] 现有设置功能正常
- [ ] 单元测试通过:`python -m unittest discover -s tests -v`
- [ ] 前端页面关键路径抽样回归(登录、分组/账号、邮件查看)
---
## 重要提示
### 开发原则
1. **小步快跑**:每完成一个小功能就测试,不要一次性写太多代码
2. **测试驱动**:先写测试用例,再实现功能
3. **代码复用**:优先使用现有的函数和组件,避免重复造轮子
4. **错误处理**:所有异步操作都要有 try-catch,所有API调用都要处理错误
5. **性能优先**:注意性能指标,使用性能分析工具验证
### 常见陷阱
1. **验证码识别**:不要过度依赖智能识别,保底规则很重要
2. **浏览器兼容性**:Clipboard API 需要降级方案
3. **轮询性能**:高频轮询会影响性能,使用异步请求
4. **内存泄漏**:记得在页面卸载时清理定时器
5. **状态同步**:全选状态要与邮箱列表实时同步
### 调试技巧
```javascript
// 开启调试日志
const DEBUG = true;
function log(...args) {
if (DEBUG) console.log('[DEBUG]', ...args);
}
// 性能测试
console.time('全选性能');
selectAllAccounts();
console.timeEnd('全选性能');
// 网络请求监控
// 打开浏览器开发者工具 -> Network 标签
```
---
## 开始执行
现在请按照以下步骤开始执行:
1. **阅读所有参考文档**(PRD、FD、TDD、TODO、测试文档)
2. **完成阶段0的准备工作**(确认测试通过、准备测试数据)
3. **从阶段1开始逐步实现**(后端 → 前端 → 优化)
4. **每个阶段完成后进行自测**(单元测试 + 手动测试)
5. **最终完成所有验收清单**(37项验收标准)
**预计总时间**:8-12天(P0必须完成)
**里程碑**:
- 里程碑1:后端API完成,单元测试通过(3天)
- 里程碑2:前端基础功能完成,手动测试通过(6天)
- 里程碑3:轮询与通知功能完成,集成测试通过(9天)
- 里程碑4:所有验收清单通过,文档更新完成(12天)
祝你开发顺利!如有疑问,请参考技术设计文档(TDD)中的详细说明。
@@ -0,0 +1,524 @@
# AI 工作提示词:Legacy 迁移到 Controllers 层
## 角色定义
你是一名高级 Python/Flask 开发工程师,负责将 Outlook 邮件管理工具的 legacy.py(5757 行)迁移到清晰的四层架构(routes → controllers → services → repositories)。
你的核心职责是:
- 按照既定的技术方案执行代码迁移
- 确保所有功能和 API 契约保持不变
- 编写和运行测试验证迁移正确性
- 保持代码质量和可维护性
---
## 项目背景
### 项目简介
**项目名称:** Outlook 邮件管理工具
**技术栈:**
- 后端:Python 3.8+, Flask 3.0+, SQLite
- 前端:原生 JavaScript, HTML, CSS(零构建)
- 测试:unittest, unittest.mock, coverage
**当前问题:**
- `outlook_web/legacy.py` 文件达到 5757 行
- 包含所有路由处理逻辑(54 个 API 路由)
- 职责混乱:路由处理 + 数据访问 + 业务逻辑混在一起
- 难以维护、测试和扩展
**迁移目标:**
```
当前架构:
routes/ (Blueprint) → legacy.py (5757行) → services/ → repositories/
目标架构:
routes/ (Blueprint) → controllers/ (新增) → services/ → repositories/
```
### 已完成的工作
✅ **文档准备完成:**
1. PRD(产品需求文档)- `docs/PRD/PRD-00003-Legacy代码拆分到Controllers层.md`
2. FD(功能设计文档)- `docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md`
3. TDD(技术设计文档)- `docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md`
4. TODO(待办清单)- `docs/TODO/Outlook邮件管理工具-Legacy迁移到Controllers层TODO.md`
5. TEST(测试文档)- `docs/TEST/Outlook邮件管理工具-Legacy迁移到Controllers层-测试文档.md`
✅ **架构准备完成:**
- routes/ 目录已存在(11 个 Blueprint 模块)
- services/ 目录已存在(7 个业务逻辑模块)
- repositories/ 目录已存在(8 个数据访问模块)
- 所有 Blueprint 当前使用 `impl=legacy` 模式
---
## 任务目标
### 总体目标
将 legacy.py 中的 54 个路由处理函数迁移到新的 controllers/ 层,分 4 个阶段完成:
**阶段 1:基础模块(19 个路由,预计 2-3 天)**
- groups (6), tags (4), settings (3), system (3), audit (1), pages (3)
**阶段 2:独立功能模块(6 个路由,预计 1-2 天)**
- temp_emails (3), oauth (2), scheduler (1)
**阶段 3:核心复杂模块(24 个路由,预计 3-4 天)**
- emails (4), accounts (20)
**阶段 4:清理和优化(预计 1 天)**
- 删除 legacy.py,迁移工具函数和中间件
### 核心要求
**必须保持不变:**
- ✅ 所有 API 的 URL 路径
- ✅ 所有 API 的 HTTP 方法
- ✅ 所有 API 的请求参数
- ✅ 所有 API 的响应格式
- ✅ 所有 API 的错误格式
- ✅ trace_id 机制
- ✅ 数据脱敏机制
- ✅ 鉴权机制(@login_required)
**必须提升:**
- ✅ 代码结构清晰
- ✅ 职责分离明确
- ✅ 易于测试和维护
---
## 工作流程
### 单个模块迁移流程(以 groups 为例)
#### 步骤 1:创建 Controller 文件
```bash
# 创建 controllers 目录(如果不存在)
mkdir -p outlook_web/controllers
# 创建 groups controller
touch outlook_web/controllers/groups.py
```
#### 步骤 2:从 legacy.py 提取函数
打开 `outlook_web/legacy.py`,找到以下函数:
- `api_get_groups()`
- `api_get_group(group_id)`
- `api_add_group()`
- `api_update_group(group_id)`
- `api_delete_group(group_id)`
- `api_export_group(group_id)`
复制这些函数到 `outlook_web/controllers/groups.py`
#### 步骤 3:调整导入语句
```python
# outlook_web/controllers/groups.py
from flask import request, jsonify
from outlook_web.security.auth import login_required
from outlook_web.repositories import groups as groups_repo
from outlook_web.errors import build_error_payload
@login_required
def api_get_groups():
"""获取所有分组"""
try:
groups = groups_repo.get_all_groups()
return jsonify(groups)
except Exception as e:
return jsonify(build_error_payload(str(e))), 500
# ... 其他函数
```
**关键点:**
- 保持函数签名完全一致
- 保持装饰器(@login_required)
- 保持错误处理逻辑
- 保持响应格式(jsonify)
#### 步骤 4:更新 routes/groups.py
```python
# outlook_web/routes/groups.py
from flask import Blueprint
from outlook_web.controllers import groups as groups_controller
def create_blueprint() -> Blueprint:
"""创建 groups Blueprint"""
bp = Blueprint("groups", __name__)
bp.add_url_rule("/api/groups",
view_func=groups_controller.api_get_groups,
methods=["GET"])
bp.add_url_rule("/api/groups/<int:group_id>",
view_func=groups_controller.api_get_group,
methods=["GET"])
# ... 其他路由
return bp
```
**关键点:**
- 移除 `impl` 参数
- 直接导入 controller 模块
- 保持 URL 路径和 HTTP 方法不变
#### 步骤 5:更新 app.py
```python
# outlook_web/app.py
# 更新前
app.register_blueprint(groups.create_blueprint(impl=legacy))
# 更新后
app.register_blueprint(groups.create_blueprint())
```
#### 步骤 6:运行测试
```bash
# 运行所有测试
python -m unittest discover -s tests -v
# 运行特定测试
python -m unittest tests.test_smoke_contract -v
```
**验证:**
- 所有测试通过
- 手动测试所有 6 个 API
- 验证响应格式正确
- 验证错误处理正确
#### 步骤 7:提交 Git
```bash
git add outlook_web/controllers/groups.py outlook_web/routes/groups.py outlook_web/app.py
git commit -m "feat: 迁移 groups 模块到 controllers 层"
```
---
## 技术规范
### Controller 标准模式
```python
# outlook_web/controllers/xxx.py
from flask import request, jsonify
from outlook_web.security.auth import login_required
from outlook_web.repositories import xxx as xxx_repo
from outlook_web.errors import build_error_payload
@login_required
def api_xxx():
"""函数说明"""
try:
# 1. 参数解析
param = request.args.get('param')
# 2. 参数验证
if not param:
return jsonify(build_error_payload('参数错误')), 400
# 3. 调用 service/repository
result = xxx_repo.do_something(param)
# 4. 返回响应
return jsonify(result)
except Exception as e:
# 5. 错误处理
return jsonify(build_error_payload(str(e))), 500
```
### Controller 职责边界
**必须做:**
- ✅ 参数解析(从 request 中提取)
- ✅ 参数验证(基本验证)
- ✅ 鉴权检查(@login_required)
- ✅ 调用 services/repositories
- ✅ 响应封装(jsonify)
- ✅ 错误处理(try-except)
**不应该做:**
- ❌ 直接操作数据库
- ❌ 复杂的业务逻辑
- ❌ 直接调用第三方 API
- ❌ 数据加密/解密
### 依赖方向规则
```
routes → controllers → services → repositories → db
```
**禁止反向依赖:**
- ❌ repositories → services
- ❌ services → controllers
- ❌ controllers → routes
---
## 测试策略
### 测试类型
**1. 回归测试(最重要)**
```bash
# 每次迁移后都要运行
python -m unittest discover -s tests -v
```
**2. 手动测试**
- 使用 curl 或 Postman 测试每个 API
- 验证响应格式正确
- 验证错误处理正确
**3. 性能测试(可选)**
```python
import time
start = time.time()
response = client.get('/api/groups')
end = time.time()
print(f"响应时间: {(end - start) * 1000:.2f}ms")
```
### 测试检查清单
每个模块迁移后:
- [ ] 所有现有测试通过
- [ ] 手动测试所有 API
- [ ] 验证响应格式不变
- [ ] 验证错误格式不变
- [ ] 验证性能无明显下降
- [ ] 提交 Git
---
## 参考文档
### 必读文档
1. **TODO 文档**(最重要)
- 位置:`docs/TODO/Outlook邮件管理工具-Legacy迁移到Controllers层TODO.md`
- 内容:详细的任务列表和执行步骤
2. **TDD 文档**
- 位置:`docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md`
- 内容:技术设计细节和代码示例
3. **TEST 文档**
- 位置:`docs/TEST/Outlook邮件管理工具-Legacy迁移到Controllers层-测试文档.md`
- 内容:测试策略和测试用例
### 可选文档
4. **PRD 文档**
- 位置:`docs/PRD/PRD-00003-Legacy代码拆分到Controllers层.md`
- 内容:需求背景和目标架构
5. **FD 文档**
- 位置:`docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md`
- 内容:功能清单和验收标准
6. **开发者指南**
- 位置:`docs/DEV/00002-前后端拆分-开发者指南.md`
- 内容:项目架构和开发规范
7. **CLAUDE.md**
- 位置:`CLAUDE.md`
- 内容:项目概述和常用命令
---
## 验收标准
### 阶段验收
**每个阶段完成后:**
- [ ] 所有测试通过
- [ ] 所有 API 手动测试通过
- [ ] 代码审查通过
- [ ] Git 提交完成
### 最终验收
**所有阶段完成后:**
- [ ] 所有 54 个路由已迁移
- [ ] legacy.py 已删除
- [ ] 所有测试通过
- [ ] 性能测试通过(响应时间 < 迁移前 110%)
- [ ] 文档已更新
---
## 注意事项
### 关键原则
1. **保持兼容性**
- 不改变任何 API 的行为
- 不改变任何响应格式
- 不改变任何错误格式
2. **分阶段迁移**
- 先迁移简单模块,后迁移复杂模块
- 每个模块迁移后立即测试
- 每个阶段完成后提交 Git
3. **充分测试**
- 每次迁移后运行所有测试
- 手动测试所有 API
- 验证性能无下降
4. **保留 legacy.py**
- 直到所有模块迁移完成
- 作为参考和回滚依据
5. **及时提交**
- 每个模块迁移后提交 Git
- 便于回滚和代码审查
### 常见问题
**Q1:如果测试失败怎么办?**
A1:
1. 检查函数签名是否一致
2. 检查导入语句是否正确
3. 检查装饰器是否保留
4. 检查错误处理是否一致
5. 如果无法解决,回滚到上一个提交
**Q2:如何处理循环依赖?**
A2:
1. 确保依赖方向正确(routes → controllers → services → repositories)
2. 不要在 repositories 中导入 services
3. 不要在 services 中导入 controllers
**Q3:如何验证 API 契约不变?**
A3:
1. 运行现有的契约测试
2. 手动测试 API,对比响应格式
3. 使用 curl 或 Postman 测试
**Q4:性能下降怎么办?**
A4:
1. 检查是否有不必要的数据库查询
2. 检查是否有重复的函数调用
3. 使用 profiler 工具分析瓶颈
---
## 快速开始
### 第一步:环境准备
```bash
# 1. 确认环境
python --version # 应该是 3.8+
pip list | grep Flask # 应该是 3.0+
# 2. 安装依赖
pip install -r requirements.txt
# 3. 运行测试(建立基线)
python -m unittest discover -s tests -v
# 4. 创建分支
git checkout -b feature/migrate-to-controllers
```
### 第二步:开始迁移
```bash
# 1. 阅读 TODO 文档
cat docs/TODO/Outlook邮件管理工具-Legacy迁移到Controllers层TODO.md
# 2. 创建 controllers 目录
mkdir -p outlook_web/controllers
touch outlook_web/controllers/__init__.py
# 3. 开始迁移第一个模块(groups)
# 按照上面的"单个模块迁移流程"执行
```
### 第三步:验证和提交
```bash
# 1. 运行测试
python -m unittest discover -s tests -v
# 2. 手动测试
curl -X GET http://localhost:5000/api/groups
# 3. 提交
git add .
git commit -m "feat: 迁移 groups 模块到 controllers 层"
```
---
## 工作建议
### 推荐工作顺序
1. **先读文档**(30 分钟)
- 阅读 TODO 文档,了解任务列表
- 阅读 TDD 文档,了解技术细节
- 阅读 TEST 文档,了解测试策略
2. **建立基线**(15 分钟)
- 运行所有测试,记录结果
- 手动测试几个关键 API
- 记录性能基线
3. **迁移第一个模块**(1-2 小时)
- 选择最简单的模块(groups 或 tags)
- 严格按照流程执行
- 充分测试验证
4. **迁移其他模块**(重复)
- 按照 TODO 文档的顺序
- 每个模块迁移后立即测试
- 每个阶段完成后提交
5. **最终清理**(1 天)
- 删除 legacy.py
- 迁移工具函数
- 更新文档
### 时间估算
- 阶段 1(基础模块):2-3 天
- 阶段 2(独立模块):1-2 天
- 阶段 3(复杂模块):3-4 天
- 阶段 4(清理优化):1 天
- **总计:7-10 天**
---
## 联系和支持
如果遇到问题:
1. 查看文档中的"常见问题"部分
2. 检查 Git 提交历史,查看之前的实现
3. 运行测试,查看错误信息
4. 如果无法解决,回滚到上一个提交
---
**祝你工作顺利!记住:保持兼容性、充分测试、及时提交。**
+1000
View File
@@ -0,0 +1,1000 @@
# PRD - 可调整布局系统
## 文档信息
- **文档编号**: PRD-00001
- **创建日期**: 2026-02-26
- **版本**: v1.1
- **状态**: 已审查
- **负责人**: 开发团队
- **审查日期**: 2026-02-26
- **审查结果**: 通过(已根据审查意见修订)
## 一、需求背景
### 1.1 当前问题
Outlook 邮件管理工具当前采用固定宽度的四栏布局:
- 分组面板(200px)
- 账号面板(260px)
- 邮件列表面板(380px)
- 邮件详情面板(自适应)
存在以下问题:
1. **缺乏灵活性**:用户无法根据使用习惯调整面板宽度
2. **空间利用率低**:不同屏幕尺寸下,固定宽度导致空间浪费或拥挤
3. **无法聚焦**:无法临时折叠不需要的面板,专注于当前任务
4. **用户体验落后**:现代编辑器(VS Code、EnsoAI)普遍支持可调整布局
### 1.2 需求来源
参考 EnsoAI 编辑器的 UI 设计理念,提升用户体验,使布局更加灵活和现代化。
### 1.3 目标用户
- **主要用户**:桌面浏览器用户(Chrome、Edge、Firefox、Safari)
- **使用场景**:
- 需要同时查看多个邮箱账号的用户
- 需要在不同工作模式间切换的用户(浏览模式 vs 专注阅读模式)
- 使用不同屏幕尺寸的用户(笔记本 vs 外接显示器)
## 二、产品目标
### 2.1 核心目标
1. **提升灵活性**:用户可以自由调整每个面板的宽度
2. **支持折叠**:用户可以临时折叠不需要的面板
3. **状态持久化**:记住用户的布局偏好,刷新后自动恢复
4. **平滑体验**:所有交互都有流畅的动画过渡
### 2.2 成功指标
- 用户可以在 3 秒内完成面板宽度调整
- 用户可以在 1 秒内完成面板折叠/展开
- 布局状态在页面刷新后 100% 恢复
- 拖动调整时帧率保持在 60fps
## 三、功能需求
### 3.1 可拖动调整宽度
#### 3.1.1 功能描述
用户可以通过拖动面板边缘的分隔条来调整面板宽度。
#### 3.1.2 交互细节
**拖动区域**:
- 每个面板右侧边缘有 4px 宽的拖动区域(resizer)
- 鼠标悬停时显示视觉反馈:
- 光标变为 `col-resize`(双向箭头)
- 显示 2px 的高亮边框指示器
- 背景色变为半透明黑色(rgba(26, 26, 26, 0.1))
**拖动行为**:
- 鼠标按下(mousedown)时开始拖动
- 鼠标移动(mousemove)时实时更新面板宽度
- 鼠标释放(mouseup)时结束拖动,保存宽度到 localStorage
**宽度限制**:
- **最小宽度限制**:
- 分组面板:150px(确保分组名称和图标可见)
- 账号面板:180px(确保邮箱地址可读)
- 邮件列表面板:280px(确保邮件预览可读)
- 邮件详情面板:400px(确保邮件内容可读)
- **最大宽度限制**:
- 分组面板:400px
- 账号面板:500px
- 邮件列表面板:600px
- 邮件详情面板:无限制(自动填充)
- **窗口尺寸自适应**:
- 当浏览器窗口宽度 < 1200px 时,自动折叠分组面板
- 当浏览器窗口宽度 < 900px 时,自动折叠账号面板
- 当浏览器窗口宽度 < 700px 时,自动折叠邮件列表面板
- 确保邮件详情面板始终保持至少 400px 可见宽度
**视觉反馈**:
- 拖动时整个页面光标变为 `col-resize`
- 拖动时添加 `user-select: none` 防止选中文本
- 使用 CSS transitions 实现平滑过渡(200ms ease)
#### 3.1.3 技术实现
- 使用 CSS Grid 布局
- 使用 CSS 变量存储面板宽度:
- `--group-panel-width`
- `--account-panel-width`
- `--email-list-panel-width`
- 使用原生 JavaScript 实现拖动逻辑
- 使用 requestAnimationFrame 优化性能
### 3.2 面板折叠/展开
#### 3.2.1 功能描述
用户可以点击折叠按钮来隐藏/显示面板,专注于当前任务。
#### 3.2.2 交互细节
**折叠按钮位置**:
- 分组面板:右上角
- 账号面板:右上角
- 邮件列表面板:右上角
**按钮样式**:
- 图标:左箭头(←)表示折叠,右箭头(→)表示展开
- 尺寸:24x24px
- 悬停效果:背景色变为 #f5f5f5
**折叠动画**:
- 使用 CSS transition 实现平滑动画(200ms ease)
- 折叠时宽度变为 0,同时 opacity 从 1 到 0
- 展开时恢复到折叠前的宽度,opacity 从 0 到 1
**折叠状态指示**:
- 折叠后在主容器边缘显示一个细条(48px 宽)
- 细条上显示面板名称(竖排文字)
- 点击细条可以快速展开面板
#### 3.2.3 折叠规则
- 分组面板、账号面板、邮件列表面板可以独立折叠
- 邮件详情面板不可折叠(始终可见)
- 至少保持一个侧边栏面板展开(不能全部折叠)
- **折叠时的拖动行为**:
- 当面板处于折叠状态时,该面板的 resizer(拖动区域)自动隐藏
- 用户必须先点击折叠指示条展开面板后,才能拖动调整宽度
- 展开时恢复到折叠前的宽度(从 localStorage 读取)
### 3.3 恢复默认布局
#### 3.3.1 功能描述
用户可以一键恢复到默认布局,清除所有自定义调整。
#### 3.3.2 交互细节
**触发位置**:
- 在顶部导航栏的右侧添加"恢复默认布局"按钮
- 图标:重置图标(↻)
- 文字:仅在悬停时显示 tooltip
**确认机制**:
- 点击后弹出确认对话框:
- 标题:"恢复默认布局"
- 内容:"确定要恢复到默认布局吗?当前的面板宽度和折叠状态将被重置。"
- 按钮:"取消" / "确定"
**默认布局参数**:
- 分组面板:200px,展开
- 账号面板:260px,展开
- 邮件列表面板:380px,展开
- 邮件详情面板:自适应
**重置动画**:
- 使用 CSS transition 平滑过渡到默认宽度(300ms ease)
### 3.4 状态持久化
#### 3.4.1 功能描述
自动保存用户的布局偏好,页面刷新后恢复。
#### 3.4.2 保存内容
**面板宽度**:
- 分组面板宽度
- 账号面板宽度
- 邮件列表面板宽度
**折叠状态**:
- 分组面板是否折叠
- 账号面板是否折叠
- 邮件列表面板是否折叠
#### 3.4.3 存储方式
- 使用 localStorage 存储
- **存储键名**:`outlook_layout_state_${userId}`(支持多用户隔离)
- 如果用户未登录,使用 `outlook_layout_state_guest`
- 如果系统有用户 ID,使用 `outlook_layout_state_${userId}`
- 数据格式:
```json
{
"version": "1.1",
"userId": "user123",
"timestamp": 1709020800000,
"panels": {
"groups": {
"width": "200px",
"collapsed": false
},
"accounts": {
"width": "260px",
"collapsed": false
},
"emails": {
"width": "380px",
"collapsed": false
}
}
}
```
#### 3.4.4 保存时机
- 拖动调整宽度后,延迟 500ms 保存(防抖)
- 折叠/展开面板后,立即保存
- 恢复默认布局后,清除保存的状态
- **页面卸载时强制保存**:
- 监听 `beforeunload` 事件
- 如果有未保存的防抖任务,立即执行保存
- 确保用户的最后一次调整不会丢失
#### 3.4.5 加载时机
- 页面加载完成后,从 localStorage 读取状态
- 如果存在保存的状态,应用到 CSS 变量
- 如果不存在,使用默认布局
## 四、非功能需求
### 4.1 性能要求
- **拖动流畅度**:拖动时帧率保持在 60fps
- **动画性能**:使用 CSS transform 和 opacity,避免触发 reflow
- **内存占用**:localStorage 数据不超过 1KB
### 4.2 兼容性要求
- **浏览器支持**:
- Chrome 90+
- Edge 90+
- Firefox 88+
- Safari 14+
- **CSS 特性**:
- CSS Grid
- CSS 变量
- CSS transitions
- **JavaScript 特性**:
- ES6+
- localStorage API
- requestAnimationFrame
### 4.3 可访问性要求
- **折叠按钮**:
- 支持键盘操作(Tab 聚焦 + Enter 触发)
- 有合适的 aria-label(如"折叠分组面板")
- **拖动区域(Resizer)**:
- 支持键盘操作:
- Tab 键聚焦到 resizer
- 左箭头键(←):减小面板宽度(每次 10px)
- 右箭头键(→):增大面板宽度(每次 10px)
- Shift + 左/右箭头:快速调整(每次 50px)
- 添加 ARIA 属性:
- `role="separator"`
- `aria-orientation="vertical"`
- `aria-label="调整[面板名称]宽度"`
- `aria-valuenow`:当前宽度值
- `aria-valuemin`:最小宽度
- `aria-valuemax`:最大宽度
- 聚焦时有明显的视觉反馈(outline 或高亮边框)
- **所有交互元素**:
- 有明确的视觉反馈
- 支持屏幕阅读器
### 4.4 安全性要求
- localStorage 数据仅存储布局状态,不包含敏感信息
- 防止 XSS 攻击:不直接将用户输入插入 DOM
## 五、UI 设计规范
### 5.1 布局结构
```
┌─────────────────────────────────────────────────────────┐
│ 顶部导航栏(固定高度 56px) │
├──────┬──────┬──────┬────────────────────────────────────┤
│ 分组 │ 账号 │ 邮件 │ 邮件详情 │
│ 面板 │ 面板 │ 列表 │ 面板 │
│ │ │ 面板 │ (自适应) │
│ [▼] │ [▼] │ [▼] │ │
│ ├─┤ │ ├─┤ │ ├─┤ │ │
└──────┴──────┴──────┴────────────────────────────────────┘
200px 260px 380px flex: 1
[▼] = 折叠按钮
├─┤ = 拖动分隔条
```
### 5.2 颜色规范
- **拖动区域背景(hover)**: rgba(26, 26, 26, 0.1)
- **拖动指示器**: #1a1a1a
- **折叠按钮背景(hover)**: #f5f5f5
- **折叠按钮图标**: #666
### 5.3 尺寸规范
- **拖动区域宽度**: 4px
- **拖动指示器宽度**: 2px
- **折叠按钮尺寸**: 24x24px
- **折叠后细条宽度**: 48px
### 5.4 动画规范
- **拖动过渡**: 无(实时响应)
- **折叠/展开**: 200ms ease
- **恢复默认布局**: 300ms ease
## 六、技术实现方案
### 6.1 技术栈
- **前端框架**: 无(原生 JavaScript)
- **CSS 预处理器**: 无(原生 CSS)
- **布局系统**: CSS Gr- **状态管理**: localStorage
### 6.2 核心模块
#### 6.2.1 布局管理器(LayoutManager)
**职责**:
- 管理面板宽度和折叠状态
- 处理拖动事件
- 处理折叠/展开事件
- 保存/加载布局状态
**主要方法**:
```javascript
class LayoutManager {
constructor()
init()
startResize(e)
resize(e)
stopResize()
togglePanel(panelName)
resetLayout()
saveState()
loadState()
updatePanelWidth(panel, width)
}
```
#### 6.2.2 状态持久化(StateManager)
**职责**:
- 读写 localStorage
- 数据格式验证
- 版本兼容性处理
**主要方法**:
```javascript
class StateManager {
save(state)
load()
clear()
validate(state)
}
```
### 6.3 文件结构
```
static/
├── css/
│ ├── main.css # 现有样式(需修改)
│ └── layout.css # 新增:布局系统样式
├── js/
│ ├── main.js # 现有脚本(需修改)
│ ├── layout-manager.js # 新增:布局管理器
│ └── state-manager.js # 新增:状态管理器
templates/
└── index.html # 主页面(需修改)
```
### 6.4 关键代码片段
#### CSS 变量定义
```css
:root {
/* 面板初始宽度 */
--group-panel-width: 200px;
--account-panel-width: 260px;
--email-list-panel-width: 380px;
/* 最小/最大宽度限制 */
--group-panel-min-width: 150px;
--group-panel-max-width: 400px;
--account-panel-min-width: 180px;
--account-panel-max-width: 500px;
--email-list-panel-min-width: 280px;
--email-list-panel-max-width: 600px;
--email-detail-panel-min-width: 400px;
/* 折叠状态宽度 */
--panel-collapsed-width: 0px;
--panel-collapsed-indicator-width: 48px;
/* 拖动区域 */
--resizer-width: 4px;
/* 动画时长 */
--panel-transition-duration: 200ms;
}
```
#### Grid 布局
```css
.main-container {
display: grid;
grid-template-columns:
minmax(var(--group-panel-min-width), var(--group-panel-width))
minmax(var(--account-panel-min-width), var(--account-panel-width))
minmax(var(--email-list-panel-min-width), var(--email-list-panel-width))
minmax(var(--email-detail-panel-min-width), 1fr);
height: calc(100vh - 56px);
transition: grid-template-columns var(--panel-transition-duration) ease;
}
/* 窗口尺寸自适应 */
@media (max-width: 1200px) {
.main-container {
grid-template-columns:
0px
minmax(var(--account-panel-min-width), var(--account-panel-width))
minmax(var(--email-list-panel-min-width), var(--email-list-panel-width))
minmax(var(--email-detail-panel-min-width), 1fr);
}
.group-panel {
display: none;
}
}
@media (max-width: 900px) {
.main-container {
grid-template-columns:
0px
0px
minmax(var(--email-list-panel-min-width), var(--email-list-panel-width))
minmax(var(--email-detail-panel-min-width), 1fr);
}
.group-panel,
.account-panel {
display: none;
}
}
@media (max-width: 700px) {
.main-container {
grid-template-columns: 1fr;
}
.group-panel,
.account-panel,
.email-list-panel {
display: none;
}
}
```
#### 拖动区域样式
```css
.resizer {
position: absolute;
top: 0;
right: 0;
width: var(--resizer-width);
height: 100%;
cursor: col-resize;
z-index: 10;
transition: background-color 0.2s;
/* 可访问性:支持键盘聚焦 */
outline: none;
}
.resizer:hover,
.resizer:focus {
background-color: rgba(26, 26, 26, 0.1);
}
.resizer:focus {
outline: 2px solid #1a1a1a;
outline-offset: -2px;
}
.resizer::after {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 2px;
background-color: transparent;
transform: translateX(-50%);
transition: background-color 0.2s;
}
.resizer:hover::after,
.resizer:focus::after {
background-color: #1a1a1a;
}
/* 折叠状态下隐藏 resizer */
.resizable-panel.collapsed .resizer {
display: none;
}
```
#### Resizer HTML 结构(支持可访问性)
```html
<div class="resizer"
role="separator"
aria-orientation="vertical"
aria-label="调整分组面板宽度"
aria-valuenow="200"
aria-valuemin="150"
aria-valuemax="400"
tabindex="0"
data-resizer="groups">
</div>
```
#### JavaScript 核心逻辑(含所有改进)
```javascript
class LayoutManager {
constructor() {
this.isResizing = false;
this.currentPanel = null;
this.startX = 0;
this.startWidth = 0;
this.pendingSave = null;
this.init();
}
init() {
// 鼠标拖动
document.querySelectorAll('.resizer').forEach(resizer => {
resizer.addEventListener('mousedown', this.startResize.bind(this));
// 键盘支持
resizer.addEventListener('keydown', this.handleKeyboard.bind(this));
});
document.addEventListener('mousemove', this.resize.bind(this));
document.addEventListener('mouseup', this.stopResize.bind(this));
// 窗口尺寸变化监听
window.addEventListener('resize', this.handleWindowResize.bind(this));
// 页面卸载时强制保存
window.addEventListener('beforeunload', this.saveStateNow.bind(this));
}
startResize(e) {
this.isResizing = true;
const resizerType = e.target.dataset.resizer;
this.currentPanel = document.querySelector(`[data-panel="${resizerType}"]`);
this.startX = e.clientX;
this.startWidth = this.currentPanel.offsetWidth;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
resize(e) {
if (!this.isResizing) return;
const delta = e.clientX - this.startX;
let newWidth = this.startWidth + delta;
// 应用最小/最大宽度限制
const panelType = this.currentPanel.dataset.panel;
const minWidth = this.getMinWidth(panelType);
const maxWidth = this.getMaxWidth(panelType);
newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));
// 确保邮件详情面板至少 400px
const containerWidth = document.querySelector('.main-container').offsetWidth;
const otherPanelsWidth = this.calculateOtherPanelsWidth(panelType);
const maxAllowedWidth = containerWidth - otherPanelsWidth - 400;
newWidth = Math.min(newWidth, maxAllowedWidth);
this.updatePanelWidth(this.currentPanel, newWidth);
}
stopResize() {
if (this.isResizing) {
this.isResizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
this.saveStateDebounced();
}
}
handleKeyboard(e) {
const resizer = e.target;
const panelType = resizer.dataset.resizer;
const panel = document.querySelector(`[data-panel="${panelType}"]`);
const currentWidth = panel.offsetWidth;
let delta = 0;
if (e.key === 'ArrowLeft') {
delta = e.shiftKey ? -50 : -10;
} else if (e.key === 'ArrowRight') {
delta = e.shiftKey ? 50 : 10;
} else {
return;
}
e.preventDefault();
let newWidth = currentWidth + delta;
const minWidth = this.getMinWidth(panelType);
const maxWidth = this.getMaxWidth(panelType);
newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));
this.updatePanelWidth(panel, newWidth);
this.updateAriaValue(resizer, newWidth);
this.saveStateDebounced();
}
handleWindowResize() {
const windowWidth = window.innerWidth;
// 自动折叠逻辑
if (windowWidth < 1200) {
this.collapsePanel('groups', true);
}
if (windowWidth < 900) {
this.collapsePanel('accounts', true);
}
if (windowWidth < 700) {
this.collapsePanel('emails', true);
}
}
updatePanelWidth(panel, width) {
const panelType = panel.dataset.panel;
const varName = `--${panelType}-panel-width`;
document.documentElement.style.setProperty(varName, `${width}px`);
}
updateAriaValue(resizer, value) {
resizer.setAttribute('aria-valuenow', Math.round(value));
}
getMinWidth(panelType) {
const minWidths = {
'groups': 150,
'accounts': 180,
'emails': 280
};
return minWidths[panelType] || 150;
}
getMaxWidth(panelType) {
const maxWidths = {
'groups': 400,
'accounts': 500,
'emails': 600
};
return maxWidths[panelType] || 600;
}
calculateOtherPanelsWidth(excludePanel) {
const panels = ['groups', 'accounts', 'emails'];
let total = 0;
panels.forEach(panel => {
if (panel !== excludePanel) {
const el = document.querySelector(`[data-panel="${panel}"]`);
if (el && !el.classList.contains('collapsed')) {
total += el.offsetWidth;
}
}
});
return total;
}
saveStateDebounced() {
if (this.pendingSave) {
clearTimeout(this.pendingSave);
}
this.pendingSave = setTimeout(() => {
this.saveStateNow();
}, 500);
}
saveStateNow() {
if (this.pendingSave) {
clearTimeout(this.pendingSave);
this.pendingSave = null;
}
const userId = this.getCurrentUserId() || 'guest';
const state = {
version: '1.1',
userId: userId,
timestamp: Date.now(),
panels: {
groups: {
width: getComputedStyle(document.documentElement)
.getPropertyValue('--groups-panel-width'),
collapsed: document.querySelector('[data-panel="groups"]')
.classList.contains('collapsed')
},
accounts: {
width: getComputedStyle(document.documentElement)
.getPropertyValue('--accounts-panel-width'),
collapsed: document.querySelector('[data-panel="accounts"]')
.classList.contains('collapsed')
},
emails: {
width: getComputedStyle(document.documentElement)
.getPropertyValue('--emails-panel-width'),
collapsed: document.querySelector('[data-panel="emails"]')
.classList.contains('collapsed')
}
}
};
localStorage.setItem(`outlook_layout_state_${userId}`, JSON.stringify(state));
}
getCurrentUserId() {
// 从页面或全局变量获取用户 ID
return window.currentUserId || null;
}
collapsePanel(panelType, auto = false) {
const panel = document.querySelector(`[data-panel="${panelType}"]`);
if (!panel) return;
panel.classList.add('collapsed');
if (!auto) {
this.saveStateNow();
}
}
}
```
## 七、实施计划
### 7.1 开发阶段
#### 阶段一:基础布局改造(2-3 天)
**任务**:
- [ ] 修改 HTML 结构,添加 resizer 元素
- [ ] 修改 CSS,实现 Grid 布局
- [ ] 添加 CSS 变量系统
- [ ] 确保现有功能不受影响
**验收标准**:
- 页面布局正常显示
- 现有功能(邮件列表、详情查看)正常工作
#### 阶段二:拖动调整功能(2-3 天)
**任务**:
- [ ] 实现 LayoutManager 类
- [ ] 实现拖动事件处理
- [ ] 添加视觉反馈(hover 效果)
- [ ] 优化拖动性能(requestAnimationFrame)
**验收标准**:
- 可以拖动调整每个面板宽度
- 拖动流畅,帧率 60fps
- 有明确的视觉反馈
#### 阶段三:折叠功能(1-2 天)
**任务**:
- [ ] 添加折叠按钮
- [ ] 实现折叠/展开逻辑
- [ ] 添加折叠动画
- [ ] 实现折叠后的细条指示器
**验收标准**:
- 可以折叠/展开每个面板
- 动画平滑自然
- 折叠后有明确的视觉指示
#### 阶段四:状态持久化(1 天)
**任务**:
- [ ] 实现 StateManager 类
- [ ] 实现保存/加载逻辑
- [ ] 添加防抖优化
- [ ] 处理异常情况(localStorage 不可用)
**验收标准**:
- 刷新页面后布局状态恢复
- localStorage 数据格式正确
- 异常情况有降级处理
#### 阶段五:恢复默认布局(1 天)
**任务**:
- [ ] 添加重置按钮
- [ ] 实现确认对话框
- [ ] 实现重置逻辑
- [ ] 添加重置动画
**验收标准**:
- 可以一键恢复默认布局
- 有确认机制防止误操作
- 重置动画平滑
#### 阶段六:测试与优化(2-3 天)
**任务**:
- [ ] 浏览器兼容性测试
- [ ] 性能测试(帧率、内存)
- [ ] 边界情况测试
- [ ] 用户体验优化
**验收标准**:
- 所有主流浏览器正常工作
- 性能指标达标
- 无明显 bug
### 7.2 总体时间估算
- **开发时间**: 9-12 天
- **测试时间**: 2-3 天
- **总计**: 11-15 天
### 7.3 风险评估
| 风险 | 影响 | 概率 | 应对措施 |
|------|------|------|----------|
| CSS Grid 兼容性问题 | 高 | 低 | 提前测试目标浏览器,准备降级方案 |
| 拖动性能不佳 | 中 | 中 | 使用 requestAnimationFrame 优化 |
| 现有功能受影响 | 高 | 中 | 充分测试,保持向后兼容 |
| localStorage 不可用 | 低 | 低 | 降级到默认布局,不影响核心功能 |
## 八、验收标准
### 8.1 功能验收
- [ ] 可以拖动调整分组面板宽度
- [ ] 可以拖动调整账号面板宽度
- [ ] 可以拖动调整邮件列表面板宽度
- [ ] 可以折叠/展开分组面板
- [ ] 可以折叠/展开账号面板
- [ ] 可以折叠/展开邮件列表面板
- [ ] 可以一键恢复默认布局
- [ ] 刷新页面后布局状态恢复
- [ ] 拖动时有视觉反馈
- [ ] 折叠/展开有平滑动画
### 8.2 性能验收
- [ ] 拖动时帧率 ≥ 60fps
- [ ] 折叠/展开动画流畅
- [ ] localStorage 数据 ≤ 1KB
- [ ] 页面加载时间增加 ≤ 50ms
### 8.3 兼容性验收
- [ ] Chrome 90+ 正常工作
- [ ] Edge 90+ 正常工作
- [ ] Firefox 88+ 正常工作
- [ ] Safari 14+ 正常工作
### 8.4 用户体验验收
- [ ] 拖动操作直观易懂
- [ ] 折叠按钮位置合理
- [ ] 视觉反馈清晰明确
- [ ] 无明显的交互延迟
## 九、后续优化方向
### 9.1 短期优化(1-2 个月内)
1. **键盘快捷键支持**
- `Ctrl/Cmd + B` 切换左侧面板
- `Ctrl/Cmd + \` 切换邮件列表
2. **预设布局模板**
- 紧凑模式(适合小屏幕)
- 标准模式(默认)
- 宽松模式(适合大屏幕)
3. **拖动排序**
- 支持拖动调整面板顺序
### 9.2 中期优化(3-6 个月内)
1. **移动端适配**
- 抽屉式侧边栏
- 触摸手势支持
2. **多套布局方案**
- 用户可以保存多套布局配置
- 快速切换不同工作场景
3. **布局分享**
- 导出布局配置
- 导入他人的布局配置
## 十、附录
### 10.1 参考资料
- EnsoAI 项目:`E:\hushaokang\Data-code\outlookEmail\exeample\EnsoAI`
- shadcn/ui Sidebar 组件文档
- CSS Grid 布局指南
- localStorage API 文档
### 10.2 术语表
| 术语 | 说明 |
|------|------|
| 面板 | 指分组面板、账号面板、邮件列表面板、邮件详情面板 |
| Resizer | 拖动分隔条,用于调整面板宽度 |
| 折叠 | 隐藏面板内容,宽度变为 0 或显示细条 |
| 展开 | 显示面板内容,恢复到折叠前的宽度 |
| 布局状态 | 包括面板宽度和折叠状态 |
| 默认布局 | 初始的面板宽度和折叠状态 |
### 10.3 变更记录
| 版本 | 日期 | 变更内容 | 变更人 |
|------|------|----------|--------|
| v1.0 | 2026-02-26 | 初始版本 | 开发团队 |
| v1.1 | 2026-02-26 | 根据审查报告修订:<br>1. 添加最小/最大宽度限制<br>2. 添加窗口尺寸自适应策略<br>3. 支持多用户布局隔离<br>4. 添加页面卸载时强制保存<br>5. 明确折叠时的拖动行为<br>6. 增强键盘可访问性支持 | 开发团队 |
### 10.4 审查报告摘要
**审查日期**: 2026-02-26
**审查结果**: 通过(已修订)
**文档质量**: 优秀 (A)
#### 已解决的问题
🔴 **严重问题**:
1. ✅ **缺乏面板拖动的临界值约束** - 已添加明确的最小/最大宽度限制
2. ✅ **浏览器窗口尺寸变化的响应策略不明确** - 已添加媒体查询和自动折叠逻辑
🟡 **中等问题**:
3. ✅ **状态持久化缺乏用户隔离** - 存储键名改为 `outlook_layout_state_${userId}`
4. ✅ **状态保存可能丢失** - 添加 `beforeunload` 事件监听,强制保存
5. ✅ **折叠状态与拖动的逻辑冲突** - 明确折叠时隐藏 resizer
🟢 **低优先级问题**:
6. ✅ **无障碍键盘控制不足** - 添加完整的键盘支持(方向键调整宽度)
#### 改进内容
1. **宽度限制系统**:
- 分组面板:150px - 400px
- 账号面板:180px - 500px
- 邮件列表:280px - 600px
- 邮件详情:最小 400px
2. **响应式断点**:
- < 1200px:自动折叠分组面板
- < 900px:自动折叠账号面板
- < 700px:自动折叠邮件列表
3. **可访问性增强**:
- Resizer 支持 Tab 聚焦
- 支持方向键调整宽度(← / →)
- 支持 Shift + 方向键快速调整
- 完整的 ARIA 属性支持
4. **状态管理优化**:
- 多用户隔离存储
- 页面卸载时强制保存
- 防抖机制优化
---
**文档结束**
Binary file not shown.
File diff suppressed because it is too large. Load diff
+77
View File
@@ -0,0 +1,77 @@
# 完成 legacy.py 删除
## 状态: 已完成 ✅
### 完成时间
2026-02-25
### 完成的工作
1. **删除 legacy.py 文件**
- 确认所有 Python 代码中已无 legacy 导入
- 删除 `outlook_web/legacy.py`(5757 行代码)
- 95 个测试全部通过
2. **提交记录**
```
828d31f refactor: 删除 legacy.py - 完成模块化迁移
```
### 迁移总结
整个 Legacy 迁移到 Controllers 层项目已完成:
**模块迁移:**
- groups(6 个路由)✅
- tags(4 个路由)✅
- settings(3 个路由)✅
- system(3 个路由)✅
- audit(1 个路由)✅
- pages(3 个路由)✅
- temp_emails(3 个路由)✅
- oauth(2 个路由)✅
- scheduler(1 个路由)✅
- emails(4 个路由)✅
- accounts(20 个路由)✅
**基础设施迁移:**
- 中间件迁移到 `middleware/` 模块 ✅
- 调度器迁移到 `services/scheduler.py` ✅
**总计:54 个路由已迁移,legacy.py 已删除**
### 当前架构
```
outlook_web/
├── app.py # 应用工厂
├── config.py # 配置管理
├── db.py # 数据库连接
├── errors.py # 错误处理
├── routes/ # Blueprint 路由层
├── controllers/ # 控制器层 (请求处理)
├── services/ # 业务逻辑层
├── repositories/ # 数据访问层
├── middleware/ # 中间件
└── security/ # 安全模块
```
### 后续建议
根据 TODO 文档,还有以下可选任务:
1. **4.5 更新文档**
- 更新 CLAUDE.md 项目架构说明
- 更新 docs/DEV/00002-前后端拆分-开发者指南.md
2. **P1 优化**
- 检查重复代码,提取公共函数
- 提升测试覆盖率
- 性能优化
### Git 状态
```
当前分支: main
领先 origin/main 20 个提交
```
@@ -0,0 +1,120 @@
# 执行日志:可调整布局系统(00003)
- 日期:2026-02-27
- 目的:按 `docs/TODO/TODO-可调整布局系统.md` 推进未完成项(阶段五/六为主),补齐 **恢复默认布局(F3)/窗口尺寸适配(F5)/键盘可访问性(F6)**,并用可验证证据(测试/文档)确保不回归主业务功能。
---
## 2026-02-27
### 启动记录
- 本轮开始依据的目标文档:`docs/TODO/TODO-可调整布局系统.md`
- 约束:
- 不引入前端构建链(保持零构建、外链 CSS/JS)。
- 现有主业务功能(分组/账号/邮件)行为不回归。
- 关键路径用 Jest(`tests/layout-system/`)提供可验证证据。
### 计划推进范围
- 阶段五:恢复默认布局(F3.1~F3.4)
- 阶段六:窗口适配与键盘支持(F5.1~F5.3、F6.1~F6.4)
### 阶段五(进行中):恢复默认布局(F3)
- 决策:沿用现有 `.modal/.show` 交互模式(与 addGroup/addAccount 等一致),不引入新依赖;重置逻辑在 `LayoutManager.resetLayout()` 内完成,且**显式取消未到期的保存防抖**,避免“重置后又被定时器写回”导致 localStorage 未真正清空。
#### 产出
- 导航栏增加恢复按钮:
- `templates/index.html`
- 恢复确认对话框:
- `templates/partials/modals.html`(`#resetLayoutModal`)
- 重置动画与按钮样式:
- `static/css/layout.css`(`.layout-resetting`、`.reset-layout-btn`、`#resetLayoutModal .text-muted`)
- 重置逻辑与动画封装:
- `static/js/layout-manager.js`(`resetLayout()`、`withResettingAnimation()`、debounce 增加 `cancel()`)
- UI 绑定(show/hide/confirm + ESC 关闭):
- `static/js/layout-bootstrap.js`
### 阶段六(进行中):窗口适配与键盘支持(F5/F6)
- 决策:
- 窗口适配以 `LayoutManager` 统一处理:`resize` 防抖 200ms(FD F5.1),断点自动折叠/恢复仅作用于 **autoCollapsed** 面板(避免覆盖用户手动折叠偏好)。
- 键盘宽度调整直接复用 `calculateNewWidth()`,确保与鼠标拖动共享同一套最小/最大/详情最小宽度约束。
- 兼容既有“移动端隐藏列表”逻辑:隐藏时同步将 Grid 列宽置 0,避免出现“元素 display:none 但列宽仍占位”的空白列回归。
#### 产出
- 窗口适配 + 键盘调整:
- `static/js/layout-manager.js`(`handleWindowResize()`、`onWindowResize()`、`onResizerKeydown()`、`expandPanel(panelType, auto)`)
- 业务隐藏邮件列表与 Grid 列宽联动:
- `static/js/features/emails.js`(`syncEmailListVisibility()`,替换原 `.hidden` 仅改 display 的实现)
#### 验证
- 布局系统 Jest(含新增 F3/F5/F6 用例):`npm test` 通过(11 suites / 93 tests)
- 全量 Python 单测回归:`python -m unittest discover -s tests -v` 通过(95 tests)
---
### UI 问题修复:窄屏下“全部折叠后无法再展开”(F2/F5 交互回归)
#### 现象
- 当窗口较窄(触发 `@media (max-width: 768px)`)且 LayoutManager 自动折叠/用户折叠侧边栏后:
- **分组面板 / 账号面板**的折叠指示器会被一起移出屏幕(left 为负值),导致用户看起来“面板消失且再也打不开”。
- 若同时触发业务侧的“隐藏邮件列表”(`.email-list-panel.hidden`),则左侧三个区域都不可恢复,交互卡死。
#### 根因定位
- `static/css/main.css` 在窄屏下仍保留历史规则:`.group-panel,.account-panel { position: fixed; left:-280px; }`
- 新的可调整布局系统(`static/js/layout-manager.js`)在折叠时把 Grid 列宽置 0,并依赖 **折叠指示器** 提供“展开入口”。
- 当历史规则把面板整体移出屏幕时,折叠指示器作为面板子元素也一并移出,造成不可展开。
#### 修复方案
- 在布局系统初始化时,为 `<html>` 添加标记类:`layout-system-enabled`。
- 将窄屏历史侧栏滑入样式限定在 “布局系统未启用” 的场景:
- 仅对 `html:not(.layout-system-enabled)` 生效,避免影响 LayoutManager 的折叠/指示器逻辑。
#### 变更点
- `static/js/layout-bootstrap.js`:LayoutManager 初始化前添加 `layout-system-enabled`。
- `static/css/main.css`:`@media (max-width: 768px)` 内的 `.group-panel/.account-panel` 固定定位规则增加 `html:not(.layout-system-enabled)` 限定。
#### 验证
- `npm test` 通过(11 suites / 93 tests)。
- Playwright 窄屏复现验证(viewport=600):
- 修复前:groups/accounts 指示器 `x < 0`(不可见)
- 修复后:groups/accounts/emails 指示器分别落在 `x=0/48/96`(可见且可点击展开)
---
### UI 问题修复:折叠后页面出现滚动条、指示器高度观感异常(“太长”)
#### 现象
- 在桌面宽屏下手动折叠侧边栏(如:账号/邮件列表)后,页面右侧出现 **body 滚动条**。
- 因 body 被撑出滚动高度,用户会看到主容器整体被滚动,折叠指示器与布局区域的高度/对齐出现“太长/不正确”的观感。
#### 根因定位
- 折叠时 Grid 列宽被置为 `0px`(layout-width),但我们仅用 `opacity:0` 隐藏面板内容:
- 面板内容仍参与布局计算;
- 在 `0` 宽度下文本/控件会被强制换行,导致折叠面板内部出现异常的“高度溢出”;
- 溢出内容默认 `overflow: visible`,会把 body 的滚动高度撑大。
#### 修复方案
- `static/css/layout.css`:
- `.main-container` 增加 `overflow: hidden;`,阻止溢出影响 body 滚动条;
- `.main-container > *` 增加 `min-height: 0;`,允许 Grid 子项在固定高度下正确收缩;
- `.resizable-panel.collapsed > :not(.collapsed-indicator)` 增加 `display: none;`,让折叠时隐藏内容**不参与布局与滚动高度计算**。
#### 验证
- Playwright 复现验证(宽屏,折叠 accounts/emails):
- 修复前:`documentElement.scrollHeight > clientHeight`(出现滚动条)
- 修复后:`documentElement.scrollHeight === clientHeight`(滚动条消失),折叠指示器高度与主容器一致
+633
View File
@@ -0,0 +1,633 @@
# TD - 可调整布局系统
## 文档信息
- **文档编号**: TD-00001
- **创建日期**: 2026-02-26
- **版本**: v1.0
- **状态**: 草稿
- **负责人**: 测试团队
- **关联文档**:
- PRD-00001: PRD - 可调整布局系统
- FD-00001: FD - 可调整布局系统
- TDD-00001: TDD - 可调整布局系统
## 目录
1. [测试概述](#一测试概述)
2. [测试范围](#二测试范围)
3. [测试策略](#三测试策略)
4. [测试用例设计](#四测试用例设计)
5. [测试数据准备](#五测试数据准备)
6. [测试环境配置](#六测试环境配置)
7. [性能测试方案](#七性能测试方案)
8. [兼容性测试方案](#八兼容性测试方案)
9. [可访问性测试方案](#九可访问性测试方案)
10. [回归测试策略](#十回归测试策略)
11. [测试执行计划](#十一测试执行计划)
12. [缺陷管理流程](#十二缺陷管理流程)
13. [测试报告与度量](#十三测试报告与度量)
## 一、测试概述
### 1.1 测试目标
验证可调整布局系统的功能完整性、性能指标、兼容性和可访问性,确保满足 PRD 中定义的所有需求和验收标准。
### 1.2 测试原则
1. **测试优先级**:P0 功能 100% 通过,P1 功能 ≥ 95% 通过,P2 功能 ≥ 90% 通过
2. **测试覆盖率**:代码覆盖率 > 80%,功能覆盖率 100%
3. **自动化优先**:单元测试和集成测试 100% 自动化,系统测试 ≥ 70% 自动化
4. **持续测试**:每次代码提交触发自动化测试,每日执行完整回归测试
### 1.3 测试类型
| 测试类型 | 覆盖范围 | 执行方式 | 执行频率 |
|---------|---------|---------|---------|
| 单元测试 | LayoutManager、StateManager 类方法 | 自动化(Jest) | 每次提交 |
| 集成测试 | 拖动、折叠、状态保存流程 | 自动化(Playwright) | 每次提交 |
| 系统测试 | 完整用户场景 | 自动化 + 手动 | 每日 |
| 性能测试 | 帧率、内存、加载时间 | 自动化(Lighthouse) | 每周 |
| 兼容性测试 | 4 个浏览器 × 功能模块 | 自动化(BrowserStack) | 每次发布前 |
| 可访问性测试 | WCAG 2.1 AA 合规性 | 自动化(axe-core) + 手动 | 每次发布前 |
| 回归测试 | 现有功能不受影响 | 自动化 + 手动 | 每次变更后 |
### 1.4 验收标准
根据 PRD 定义的成功指标:
- ✅ 用户可以在 3 秒内完成面板宽度调整
- ✅ 用户可以在 1 秒内完成面板折叠/展开
- ✅ 布局状态在页面刷新后 100% 恢复
- ✅ 拖动调整时帧率保持在 60fps
- ✅ 所有 P0 功能测试用例 100% 通过
- ✅ 所有 P1 功能测试用例 ≥ 95% 通过
- ✅ 无 P0/P1 级别缺陷遗留
## 二、测试范围
### 2.1 功能模块测试范围
根据 FD 文档,测试范围包括 6 个主要功能模块:
#### F1 - 拖动调整宽度(P0)
| 子功能 | 优先级 | 测试点 |
|--------|--------|--------|
| F1.1 - 鼠标拖动调整 | P0 | 拖动流畅度、宽度实时更新、视觉反馈 |
| F1.2 - 宽度限制 | P0 | 最小/最大宽度限制、邮件详情面板 400px 保证 |
| F1.3 - 视觉反馈 | P1 | hover 效果、拖动光标、高亮指示器 |
| F1.4 - 性能优化 | P0 | requestAnimationFrame、60fps 保证 |
#### F2 - 面板折叠/展开(P0)
| 子功能 | 优先级 | 测试点 |
|--------|--------|--------|
| F2.1 - 折叠按钮 | P0 | 点击折叠、点击展开、按钮状态更新 |
| F2.2 - 折叠动画 | P1 | 200ms 过渡、平滑动画、无卡顿 |
| F2.3 - 折叠状态指示 | P1 | 细条显示、竖排文字、点击展开 |
| F2.4 - 折叠规则 | P0 | 至少一个面板展开、邮件详情不可折叠 |
| F2.5 - ARIA 属性更新 | P1 | aria-expanded 更新、aria-label 更新 |
| F2.6 - 折叠时隐藏 resizer | P0 | resizer 不可见、不可交互 |
#### F3 - 恢复默认布局(P1)
| 子功能 | 优先级 | 测试点 |
|--------|--------|--------|
| F3.1 - 恢复按钮 | P1 | 按钮位置、tooltip 显示 |
| F3.2 - 确认对话框 | P1 | 对话框内容、取消/确定按钮 |
| F3.3 - 恢复逻辑 | P1 | 宽度恢复、折叠状态恢复、localStorage 清除 |
| F3.4 - 恢复动画 | P2 | 300ms 过渡、平滑动画 |
#### F4 - 状态持久化(P0)
| 子功能 | 优先级 | 测试点 |
|--------|--------|--------|
| F4.1 - 保存面板宽度 | P0 | 拖动后保存、防抖 500ms |
| F4.2 - 保存折叠状态 | P0 | 折叠后立即保存 |
| F4.3 - 加载状态 | P0 | 页面加载时恢复、数据验证 |
| F4.4 - 多用户隔离 | P1 | 不同 userId 独立存储 |
| F4.5 - 页面卸载强制保存 | P0 | beforeunload 触发保存 |
#### F5 - 窗口尺寸适配(P1)
| 子功能 | 优先级 | 测试点 |
|--------|--------|--------|
| F5.1 - 断点自动折叠 | P1 | <1200px、<900px、<700px 自动折叠 |
| F5.2 - 窗口放大恢复 | P1 | 自动折叠的面板自动恢复 |
| F5.3 - 防抖优化 | P2 | 200ms 防抖、避免频繁触发 |
#### F6 - 键盘可访问性(P1)
| 子功能 | 优先级 | 测试点 |
|--------|--------|--------|
| F6.1 - Tab 聚焦 | P1 | resizer 可聚焦、折叠按钮可聚焦 |
| F6.2 - 方向键调整宽度 | P1 | ←/→ 调整、Shift+←/→ 快速调整 |
| F6.3 - Enter/Sp发 | P1 | 折叠按钮触发 |
| F6.4 - ARIA 属性 | P1 | role、aria-label、aria-valuenow 等 |
### 2.2 非功能测试范围
#### 性能测试
- 拖动帧率 ≥ 60fps
- 折叠/展开动画流畅度
- localStorage 数据 ≤ 1KB
- 页面加载时间增加 ≤ 50ms
#### 兼容性测试
- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+
#### 可访问性测试
- WCAG 2.1 AA 级别合规
- 键盘导航完整性
- 屏幕阅读器支持
#### 安全性测试
- localStorage 数据不包含敏感信息
- XSS 防护验证
### 2.3 不在测试范围内
- 移动端适配(后续版本)
- 拖动排序功能(后续版本)
- 多套布局方案(后续版本)
- 布局分享功能(后续版本)
## 三、测试策略
### 3.1 单元测试策略
**测试框架**: Jest
**覆盖目标**: 代码覆盖率 > 80%
**测试对象**:
- LayoutManager 类的所有公共方法
- StateManager 类的所有公共方法
- 核心算法(拖动宽度计算、防抖、状态序列化)
**测试方法**:
- 正向测试:正常输入,验证正确输出
- 负向测试:异常输入,验证错误处理
- 边界测试:临界值输入,验证边界处理
**Mock 策略**:
- Mock localStorage API
- Mock DOM 元素
- Mock 事件对象
### 3.2 集成测试策略
**测试框架**: Playwright
**测试对象**:
- 拖动调整宽度的完整流程
- 折叠/展开的完整流程
- 状态保存和加载的完整流程
- 窗口调整的完整流程
**测试方法**:
- 端到端测试:模拟真实用户操作
- 流程测试:验证多个功能的协作
- 数据驱动测试:使用不同的测试数据
### 3.3 系统测试策略
**测试方法**: 自动化 + 手动
**测试场景**:
- 用户首次访问(默认布局)
- 用户调整布局后刷新(状态恢复)
- 用户在不同设备间切换(多用户隔离)
- 用户调整窗口大小(自动适配)
- 用户使用键盘操作(可访问性)
### 3.4 探索性测试策略
**测试时间**: 每个功能模块 2 小时
**测试重点**:
- 异常操作路径
- 极端场景
- 用户体验问题
- 未预期的交互
## 四、测试用例设计
### 4.1 测试用例编号规则
**格式**: TC-[模块]-[序号]-[类型]
- **模块**: F1~F6(对应功能模块)
- **序号**: 001~999(三位数字)
- **类型**: P(正向)、N(负向)、B(边界)、PF(性能)
**示例**: TC-F1-001-P(F1 模块第 1 个正向用例)
### 4.2 F1 - 拖动调整宽度测试用例
#### TC-F1-001-P: 拖动分组面板到中间宽度
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 页面已加载,分组面板宽度为默认 200px |
| **测试步骤** | 1. 鼠标悬停在分组面板右侧 resizer<br>2. 按下鼠标左键<br>3. 向右拖动 50px<br>4. 释放鼠标 |
| **预期结果** | 1. resizer 显示 hover 效果(背景色变化)<br>2. 光标变为 col-resize<br>3. 面板宽度实时更新为 250px<br>4. 拖动流畅,无卡顿 |
| **关联需求** | F1.1, F1.3 |
#### TC-F1-002-B: 拖动分组面板到最小宽度
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 页面已加载,分组面板宽度为默认 200px |
| **测试步骤** | 1. 鼠标按下 resizer<br>2. 向左拖动 100px(超过最小宽度限制)<br>3. 释放鼠标 |
| **预期结果** | 面板宽度停止在最小宽度 150px,不会继续缩小 |
| **关联需求** | F1.2 |
#### TC-F1-003-B: 拖动分组面板到最大宽度
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 页面已加载,分组面板宽度为默认 200px |
| **测试步骤** | 1. 鼠标按下 resizer<br>2. 向右拖动 300px(超过最大宽度限制)<br>3. 释放鼠标 |
| **预期结果** | 面板宽度停止在最大宽度 400px,不会继续增大 |
| **关联需求** | F1.2 |
#### TC-F1-004-B: 拖动时确保邮件详情面板至少 400px
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 窗口宽度 1200px,所有面板展开 |
| **测试步骤** | 1. 拖动分组面板到最大宽度 400px<br>2. 拖动账号面板到最大宽度 500px<br>3. 尝试拖动邮件列表面板到最大宽度 600px |
| **预期结果** | 邮件列表面板宽度被限制,确保邮件详情面板至少保持 400px |
| **关联需求** | F1.2 |
#### TC-F1-005-N: 快速拖动导致鼠标移出窗口
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 页面已加载 |
| **测试步骤** | 1. 鼠标按下 resizer<br>2. 快速向右拖动,鼠标移出浏览器窗口<br>3. 在窗口外释放鼠标<br>4. 鼠标移回窗口 |
| **预期结果** | 1. 拖动正常结束<br>2. 光标恢复正常<br>3. 状态已保存 |
| **关联需求** | F1.1 |
#### TC-F1-006-PF: 拖动帧率测试
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 页面已加载 |
| **测试步骤** | 1. 使用 Performance API 监听帧率<br>2. 连续拖动 1 秒<br>3. 计算平均帧率 |
| **预期结果** | 平均帧率 ≥ 60fps(允许 5fps 误差) |
| **关联需求** | F1.4 |
### 4.3 F2 - 面板折叠/展开测试用例
#### TC-F2-001-P: 点击折叠按钮折叠面板
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 分组面板处于展开状态 |
| **测试步骤** | 1. 点击分组面板右上角的折叠按钮 |
| **预期结果** | 1. 面板宽度变为 0<br>2. 面板内容不可见<br>3. 显示折叠指示条(48px 宽)<br>4. 折叠动画流畅(200ms)<br>5. 按钮图标变为 →<br>6. aria-expanded 更新为 false |
| **关联需求** | F2.1, F2.2, F2.3, F2.5 |
#### TC-F2-002-P: 点击折叠指示条展开面板
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 分组面板处于折叠状态 |
| **测试步骤** | 1. 点击折叠指示条 |
| **预期结果** | 1. 面板恢复到折叠前的宽度<br>2. 面板内容可见<br>3. 折叠指示条消失<br>4. 展开动画流畅(200ms)<br>5. 按钮图标变为 ←<br>6. aria-expanded 更新为 true |
| **关联需求** | F2.1, F2.2, F2.3, F2.5 |
#### TC-F2-003-N: 尝试折叠所有面板
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 只有分组面板展开,其他面板已折叠 |
| **测试步骤** | 1. 点击分组面板的折叠按钮 |
| **预期结果** | 1. 显示提示信息:"至少需要保持一个面板展开"<br>2. 分组面板保持展开状态 |
| **关联需求** | F2.4 |
#### TC-F2-004-P: 折叠后 resizer 不可见
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 分组面板处于展开状态 |
| **测试步骤** | 1. 折叠分组面板<br>2. 检查 resizer 元素 |
| **预期结果** | 1. resizer 不可见(display: none)<br>2. resizer 不可交互(pointer-events: none) |
| **关联需求** | F2.6 |
#### TC-F2-005-N: 折叠状态下尝试拖动
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 分组面板处于折叠状态 |
| **测试步骤** | 1. 尝试在折叠指示条位置拖动 |
| **预期结果** | 无法拖动,必须先展开面板 |
| **关联需求** | F2.6 |
### 4.4 F3 - 恢复默认布局测试用例
#### TC-F3-001-P: 恢复默认布局
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 用户已调整布局(宽度和折叠状态) |
| **测试步骤** | 1. 点击顶部导航栏的"恢复默认布局"按钮<br>2. 在确认对话框中点击"确定" |
| **预期结果** | 1. 分组面板宽度恢复为 200px<br>2. 账号面板宽度恢复为 260px<br>3. 邮件列表面板宽度恢复为 380px<br>4. 所有面板展开<br>5. localStorage 中的状态被清除<br>6. 恢复动画流畅(300ms) |
| **关联需求** | F3.1, F3.2, F3.3, F3.4 |
#### TC-F3-002-N: 取消恢复默认布局
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 用户已调整布局 |
| **测试步骤** | 1. 点击"恢复默认布局"按钮<br>2. 在确认对话框中点击"取消" |
| **预期结果** | 1. 对话框关闭<br>2. 布局保持当前状态不变 |
| **关联需求** | F3.2 |
### 4.5 F4 - 状态持久化测试用例
#### TC-F4-001-P: 拖动后保存状态
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 页面已加载 |
| **测试步骤** | 1. 拖动分组面板到 250px<br>2. 等待 500ms(防抖时间)<br>3. 检查 localStorage |
| **预期结果** | localStorage 中保值 "250px" |
| **关联需求** | F4.1 |
#### TC-F4-002-P: 折叠后立即保存状态
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 页面已加载 |
| **测试步骤** | 1. 折叠分组面板<br>2. 立即检查 localStorage(无需等待) |
| **预期结果** | localStorage 中保存了折叠状态 collapsed: true |
| **关联需求** | F4.2 |
#### TC-F4-003-P: 页面刷新后恢复状态
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 用户已调整布局并保存 |
| **测试步骤** | 1. 刷新页面(F5)<br>2. 等待页面加载完成 |
| **预期结果** | 1. 面板宽度恢复到调整后的值<br>2. 折叠状态恢复<br>3. 恢复过程无闪烁 |
| **关联需求** | F4.3 |
#### TC-F4-004-P: 多用户布局隔离
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 系统支持多用户 |
| **测试步骤** | 1. 用户 A 登录,调整布局<br>2. 用户 A 退出<br>3. 用户 B 登录<br>4. 检查布局状态 |
| **预期结果** | 用户 B 看到的是默认布局,不受用户 A 的影响 |
| **关联需求** | F4.4 |
#### TC-F4-005-P: 页面卸载时强制保存
| 项目 | 内容 |
|------|------|
| **优先级** | P0 |
| **前置条件** | 页面已加载 |
| **测试步骤** | 1. 拖动面板<br>2. 立即关闭浏览器标签页(不等待防抖)<br>3. 重新打开页面 |
| **预期结果** | 布局状态已保存,最后一次调整未丢失 |
| **关联需求** | F4.5 |
#### TC-F4-006-N: localStorage 不可用
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 浏览器禁用 localStorage 或隐私模式 |
| **测试步骤** | 1. 加载页面<br>2. 调整布局<br>3. 刷新页面 |
| **预期结果** | 1. 页面正常加载,使用默认布局<br>2. 调整布局功能正常<br>3. 刷新后恢复默认布局(无法持久化)<br>4. 控制台显示警告n| **关联需求** | F4.3 |
#### TC-F4-007-N: localStorage 数据损坏
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | localStorage 中存在损坏的数据 |
| **测试步骤** | 1. 手动修改 localStorage 数据为无效 JSON<br>2. 刷新页面 |
| **预期结果** | 1. 页面正常加载<br>2. 使用默认布局<br>3. 损坏的数据被清除<br>4. 控制台显示错误信息 |
| **关联需求** | F4.3 |
### 4.6 F5 - 窗口尺寸适配测试用例
#### TC-F5-001-P: 窗口宽度 < 1200px 自动折叠分组面板
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 窗口宽度 > 1200px,所有面板展开 |
| **测试步骤** | 1. 调整浏览器窗口宽度到 1100px<br>2. 等待 200ms(防抖时间) |
| **预期结果** | 1. 分组面板自动折叠<br>2. 面板标记为 autoCollapsed<br>3. 其他面板保持展开 |
| **关联需求** | F5.1 |
#### TC-F5-002-P: 窗口宽度 < 900px 自动折叠账号面板
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 窗口宽度 > 900px |
| **测试步骤** | 1. 调整浏览器窗口宽度到 800px<br>2. 等待 200ms |
| **预期结果** | 1. 分组面板和账号面板自动折叠<br>2. 邮件列表和详情面板保持展开 |
| **关联需求** | F5.1 |
#### TC-F5-003-P: 窗口宽度 < 700px 自动折叠邮件列表面板
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 窗口宽度 > 700px |
| **测试步骤** | 1. 调整浏览器窗口宽度到 600px<br>2. 等待 200ms |
| **预期结果** | 1. 所有侧边栏面板自动折叠<br>2. 只显示邮件详情面板 |
| **关联需求** | F5.1 |
#### TC-F5-004-P: 窗口放大后自动恢复面板
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 窗口宽度 < 1200px,分组面板自动折叠 |
| **测试步骤** | 1. 调整浏览器窗口宽度到 1300px<br>2. 等待 200ms |
| **预期结果** | 1. 分组面板自动展开<br>2. autoCd 标记被移除 |
| **关联需求** | F5.2 |
#### TC-F5-005-N: 用户手动折叠的面板不自动恢复
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 窗口宽度 > 1200px |
| **测试步骤** | 1. 用户手动折叠分组面板<br>2. 调整窗口宽度到 1100px 再恢复到 1300px |
| **预期结果** | 分组面板保持折叠状态(因为是用户手动折叠) |
| **关联需求** | F5.2 |
### 4.7 F6 - 键盘可访问性测试用例
#### TC-F6-001-P: Tab 键聚焦到 resizer
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 页面已加载 |
| **测试步骤** | 1. 按 Tab 键多次,直到聚焦到分组面板的 resizer |
| **预期结果** | 1. resizer 获得焦点<br>2. 显示明显的视觉反馈(outline 或高亮边框) |
| **关联需求** | F6.1 |
#### TC-F6-002-P: 方向键调整宽度
| 项目 | 内容 |
|---------|
| **优先级** | P1 |
| **前置条件** | resizer 已聚焦,面板宽度 200px |
| **测试步骤** | 1. 按右箭头键 5 次<br>2. 按左箭头键 3 次 |
| **预期结果** | 1. 每次按右箭头,宽度增加 10px(最终 250px)<br>2. 每次按左箭头,宽度减少 10px(最终 220px)<br>3. aria-valuenow 实时更新 |
| **关联需求** | F6.2, F6.4 |
#### TC-F6-003-P: Shift + 方向键快速调整
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | resizer 已聚焦,面板宽度 200px |
| **测试步骤** | 1. 按 Shift + 右箭头键 2 次 |
| **预期结果** | 每次按键宽度增加 50px(最终 300px) |
| **关联需求** | F6.2 |
#### TC-F6-004-P: Enter/Space 触发折叠按钮
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 折叠按钮已聚焦 |
| **测试步骤** | 1. 按 Enter 键 |
| **预期结果** | 面板折叠 |
| **关联需求** | ### TC-F6-005-P: ARIA 属性完整性
| 项目 | 内容 |
|------|------|
| **优先级** | P1 |
| **前置条件** | 页面已加载 |
| **测试步骤** | 1. 检查 resizer 元素的 ARIA 属性 |
| **预期结果** | 1. role="separator"<br>2. aria-orientation="vertical"<br>3. aria-label="调整[面板名称]宽度"<br>4. aria-valuenow、aria-valuemin、aria-valuemax 正确设置 |
| **关联需求** | F6.4 |
## 九、可访问性测试方案
### 9.1 WCAG 2.1 AA 合规性测试
使用 axe-core 自动检测可访问性问题。
### 9.2 键盘导航测试
- Tab 键可以聚焦到所有交互元素
- 方向键可以调整面板宽度
- Enter/Space 可以触发折叠按钮
- 所有操作可通过键盘完成
### 9.3 屏幕阅读器测试
使用 NVDA/JAWS 测试 ARIA 属性是否正确朗读。
## 十、回归测试策略
### 10.1 回归测试范围
确保布局系统不影响现有功能:
- 邮件列表显示和滚动
- 邮件详情查看
- 账号切换
- 分组管理
- Token 刷新
- 临时邮箱
### 10.2 回归测试执行
每次代码变更后执行完整回归测试。
## 十一、测试执行计划
### 11.1 测试阶段
1. **阶段 1 - 单元测试**(开发完成后)
2. **阶段 2 - 集成测试**(功能集成后)
3. **阶段 3 - 系统测试**(所有功能完成后)
4. **阶段 4 - 验收测试**(测试环境部署后)
### 11.2 测试时间安排
- 单元测试:2 天
- 集成测试:3 天
- 系统测试:3 天
- 性能测试:1 天
- 兼容性测试:2 天
- 可访问性测试:1 天
- 回归测试:2 天
- **总计**:14 天
## 十二、缺陷管理流程
### 12.1 缺陷优先级
- **P0(阻塞)**:核心功能无法使用,必须立即修复
- **P1(严重)**:重要功能异常,当天修复
- **P2(一般)**:次要功能问题,3 天内修复
- **P3(轻微)**:UI 问题或优化建议,可延后
### 12.2 缺陷跟踪
使用 GitHub Issues 跟踪所有缺陷。
## 十三、测试报告与度量
### 13.1 测试报告内容
1. 测试执行摘要
2. 功能模块测试结果
3. 缺陷分布统计
4. 性能测试结果
5. 兼容性测试结果
6. 可访问性测试结果
7. 风险评估和建议
### 13.2 度量指标
- 测试覆盖率:代码覆盖率 > 80%,功能覆盖率 100%
- 缺陷密度:< 5 个/KLOC
- 缺陷修复率:P0/P1 100%,P2 > 95%
- 回归通过率:> 98%
---
**文档结束**
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+5002
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "outlook-email-layout-system",
"private": true,
"scripts": {
"test": "jest --config tests/layout-system/jest.config.js",
"test:watch": "jest --config tests/layout-system/jest.config.js --watch",
"test:coverage": "jest --config tests/layout-system/jest.config.js --coverage",
"test:unit": "jest --config tests/layout-system/jest.config.js tests/layout-system/unit",
"test:integration": "jest --config tests/layout-system/jest.config.js tests/layout-system/integration --collectCoverage=false"
},
"devDependencies": {
"jest": "^30.1.3",
"jest-environment-jsdom": "^30.1.3"
}
}
+230
View File
@@ -0,0 +1,230 @@
/**
* 可调整布局系统(基础样式)
*
* 说明:
* - 使用 CSS Grid + CSS 变量控制三列侧边栏宽度(分组/账号/邮件列表)与详情区域自适应。
* - resizer / 折叠相关样式在此文件集中管理,避免污染现有 main.css。
* - 该文件将被在 main.css 之后引入,以覆盖旧的 flex 固定宽度布局。
*/
:root {
/* ===================== 面板默认宽度 ===================== */
--group-panel-width: 200px;
--account-panel-width: 260px;
--email-list-panel-width: 380px;
/* ===================== 折叠相关宽度 ===================== */
--panel-collapsed-width: 0px;
--panel-collapsed-indicator-width: 48px;
/* ===================== 宽度限制(用于 Grid 与 JS 计算) ===================== */
--group-panel-min-width: 150px;
--group-panel-max-width: 400px;
--account-panel-min-width: 180px;
--account-panel-max-width: 500px;
--email-list-panel-min-width: 280px;
--email-list-panel-max-width: 600px;
--email-detail-panel-min-width: 400px;
/* ===================== Grid 生效宽度(折叠时可被置为 0,保存宽度不丢失) ===================== */
--group-panel-layout-width: var(--group-panel-width);
--account-panel-layout-width: var(--account-panel-width);
--email-list-panel-layout-width: var(--email-list-panel-width);
--group-panel-layout-min-width: var(--group-panel-min-width);
--account-panel-layout-min-width: var(--account-panel-min-width);
--email-list-panel-layout-min-width: var(--email-list-panel-min-width);
/* ===================== 交互与动画 ===================== */
--resizer-width: 4px;
--panel-transition-duration: 200ms;
--panel-transition-timing-function: ease;
--collapsed-indicator-width: var(--panel-collapsed-indicator-width);
}
/* ===================== Grid 布局(覆盖 main.css) ===================== */
.main-container {
display: grid;
grid-template-columns:
minmax(var(--group-panel-layout-min-width), var(--group-panel-layout-width))
minmax(var(--account-panel-layout-min-width), var(--account-panel-layout-width))
minmax(var(--email-list-panel-layout-min-width), var(--email-list-panel-layout-width))
minmax(var(--email-detail-panel-min-width), 1fr);
grid-template-rows: 1fr;
/* 防止折叠时面板内容溢出把 body 撑出滚动条(会导致“指示器高度异常/过长”观感) */
overflow: hidden;
transition: grid-template-columns var(--panel-transition-duration)
var(--panel-transition-timing-function);
will-change: grid-template-columns;
}
/* ===================== 状态恢复时禁用动画(避免闪烁) ===================== */
.layout-no-transition .main-container {
transition: none !important;
}
.layout-no-transition .resizable-panel > :not(.collapsed-indicator) {
transition: none !important;
}
/* 让 Grid 子项允许收缩,避免内容撑破布局 */
.main-container > * {
min-width: 0;
min-height: 0;
}
/* ===================== Resizable 面板基础样式 ===================== */
.resizable-panel {
position: relative;
/* 默认层级:保持低于 modal 等全局浮层 */
z-index: 1;
}
/* ===================== Resizer 样式 ===================== */
.resizer {
position: absolute;
top: 0;
right: 0;
width: var(--resizer-width);
height: 100%;
cursor: col-resize;
z-index: 20;
touch-action: none;
}
.resizer:hover,
.resizer:focus,
.resizer.active {
background-color: rgba(26, 26, 26, 0.1);
box-shadow: inset 0 0 0 2px #1a1a1a;
}
.resizer:focus-visible {
outline: 2px solid #1a1a1a;
outline-offset: 2px;
}
/* ===================== 折叠状态(基础) ===================== */
.resizable-panel {
will-change: opacity;
}
/* 折叠时仅隐藏面板内容,指示器仍需可见 */
.resizable-panel > :not(.collapsed-indicator) {
transition: opacity var(--panel-transition-duration)
var(--panel-transition-timing-function);
}
.resizable-panel.collapsed > :not(.collapsed-indicator) {
opacity: 0;
pointer-events: none;
/*
* 关键修复:
* - 折叠时列宽为 0,若仅用 opacity 隐藏,面板内文本/控件会在 0 宽下强制换行,
* 仍参与布局计算并可能把页面撑出滚动条,导致指示器“看起来过长”。
* - 直接 display:none 让隐藏内容不参与布局与滚动高度计算。
*/
display: none;
}
.resizable-panel.collapsed {
overflow: visible;
/*
* 关键修复:
* - 折叠后 Grid 列宽会变为 0,指示器需要“溢出”到右侧可见区域。
* - 由于 Grid 子项的绘制顺序受 DOM 影响(后面的面板会盖住前面的面板),
* 当多面板折叠时,指示器可能被邮件详情面板覆盖导致“消失且无法展开”。
* - 提升折叠面板的 z-index,并强制其宽度为 0,确保指示器可见且不挡住右侧交互。
*/
z-index: 200;
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
}
.resizable-panel.collapsed .resizer {
display: none;
pointer-events: none;
}
/* ===================== 折叠指示器(48px,竖排文字) ===================== */
.collapsed-indicator {
display: none;
position: absolute;
top: 0;
left: 0;
width: var(--collapsed-indicator-width);
height: 100%;
background-color: #fafafa;
border: 1px solid #e5e5e5;
box-sizing: border-box;
cursor: pointer;
z-index: 30;
align-items: center;
justify-content: center;
transform: translateX(var(--collapsed-indicator-offset, 0px));
}
.resizable-panel.collapsed .collapsed-indicator {
display: flex;
}
.collapsed-indicator:hover,
.collapsed-indicator:focus {
background-color: #f0f0f0;
}
.collapsed-indicator:focus-visible {
outline: 2px solid #1a1a1a;
outline-offset: 2px;
}
.collapsed-indicator .indicator-text {
writing-mode: vertical-rl;
font-size: 14px;
color: #666;
user-select: none;
}
/* 折叠按钮:先复用 main.css 的 .panel-action-btn 基础样式,仅补充可访问性样式 */
.collapse-btn:focus-visible {
outline: 2px solid #1a1a1a;
outline-offset: 2px;
}
/* ===================== 拖动中全局反馈 ===================== */
.layout-resizing,
.layout-resizing * {
cursor: col-resize !important;
}
.layout-resizing {
user-select: none;
-webkit-user-select: none;
}
/* ===================== F3:恢复默认布局(确认对话框细节) ===================== */
#resetLayoutModal .text-muted {
color: #999;
font-size: 12px;
margin-top: 6px;
}
/* ===================== F3:恢复默认布局(按钮) ===================== */
.reset-layout-btn {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.reset-layout-btn svg {
width: 16px;
height: 16px;
}
/* 重置动画:临时将布局过渡时长提升到 300ms(FD F3.4) */
.layout-resetting {
--panel-transition-duration: 300ms;
}
+19 -35
View File
@@ -129,7 +129,7 @@
/* 分组面板 */
.group-panel {
width: 200px;
width: var(--group-panel-width, 200px);
background-color: #fafafa;
border-right: 1px solid #e5e5e5;
display: flex;
@@ -259,7 +259,7 @@
/* 邮箱账号面板 */
.account-panel {
width: 260px;
width: var(--account-panel-width, 260px);
background-color: #ffffff;
border-right: 1px solid #e5e5e5;
display: flex;
@@ -500,7 +500,7 @@
/* 邮件列表面板 */
.email-list-panel {
width: 380px;
width: var(--email-list-panel-width, 380px);
background-color: #ffffff;
border-right: 1px solid #e5e5e5;
display: flex;
@@ -1249,39 +1249,23 @@
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* 响应式 */
@media (max-width: 1200px) {
.group-panel {
width: 180px;
}
.account-panel {
width: 220px;
}
.email-list-panel {
width: 320px;
}
}
@media (max-width: 1024px) {
.group-panel {
width: 160px;
}
.account-panel {
width: 200px;
}
.email-list-panel {
width: 280px;
}
}
/* 响应式
说明:可调整布局系统的断点折叠逻辑由 LayoutManager(JS)负责,
此处移除旧的“固定宽度缩放”规则,避免与用户拖动/持久化状态冲突。 */
@media (max-width: 768px) {
.group-panel,
.account-panel {
/*
* 移动端历史样式(侧栏滑入/滑出):
* - 旧逻辑依赖 `.show` 类控制 left。
* - 当前版本的可调整布局系统由 LayoutManager(JS)负责折叠/展开与指示器渲染;
* 若继续在窄屏下把面板强制 `left:-280px`,会导致折叠指示器也被移出屏幕,
* 出现“全部折叠后无法再展开”的严重交互问题。
* - 因此仅在布局系统未启用时保留该历史规则,避免影响可调整布局系统。
*/
html:not(.layout-system-enabled) .group-panel,
html:not(.layout-system-enabled) .account-panel {
position: fixed;
left: -280px;
top: 56px;
@@ -1290,8 +1274,8 @@
transition: left 0.3s;
}
.group-panel.show,
.account-panel.show {
html:not(.layout-system-enabled) .group-panel.show,
html:not(.layout-system-enabled) .account-panel.show {
left: 0;
}
+39 -4
View File
@@ -462,18 +462,53 @@
}
}
// 同步“邮件列表显示/隐藏”与可调整布局系统(Grid 列宽)
function syncEmailListVisibility(visible) {
const panel = document.getElementById('emailListPanel');
if (!panel) return;
try {
const lm = window.layoutManager;
if (lm && typeof lm.applyCollapsedLayoutVars === 'function' && typeof lm.applyExpandedLayoutVars === 'function') {
if (visible) {
panel.classList.remove('hidden');
// 若面板处于折叠状态,先自动展开(不抢焦点/不保存),保持“移动端临时显示”语义
if (panel.classList.contains('collapsed') && typeof lm.expandPanel === 'function') {
lm.expandPanel('emails', true);
}
lm.applyExpandedLayoutVars('emails');
} else {
panel.classList.add('hidden');
lm.applyCollapsedLayoutVars('emails');
}
return;
}
} catch (e) {
// 降级:忽略布局系统异常,不影响主业务
console.warn('syncEmailListVisibility failed:', e);
}
// 无布局系统时保持旧逻辑
if (visible) {
panel.classList.remove('hidden');
} else {
panel.classList.add('hidden');
}
}
// 切换邮件列表显示
function toggleEmailList() {
const panel = document.getElementById('emailListPanel');
const toggleText = document.getElementById('toggleListText');
isListVisible = !isListVisible;
if (isListVisible) {
panel.classList.remove('hidden');
syncEmailListVisibility(true);
toggleText.textContent = '隐藏列表';
} else {
panel.classList.add('hidden');
syncEmailListVisibility(false);
toggleText.textContent = '显示列表';
}
}
@@ -621,7 +656,7 @@
// 显示邮件列表(移动端)
function showEmailList() {
document.getElementById('emailListPanel').classList.remove('hidden');
syncEmailListVisibility(true);
isListVisible = true;
document.getElementById('toggleListText').textContent = '隐藏列表';
}
+65
View File
@@ -0,0 +1,65 @@
/**
* 可调整布局系统 - 页面初始化入口
*
* 约束:
* - 不能在 HTML 中内联 <script>(符合 tests/test_smoke_contract.py 的零构建约束)。
* - 仅在浏览器环境运行;Jest 单测直接 require layout-manager.js,不会加载本文件。
*/
document.addEventListener('DOMContentLoaded', () => {
// ==================== F3:恢复默认布局(UI 绑定) ====================
// 说明:即使布局系统初始化失败,也要保证 onclick 不抛错(仅降级为关闭/无操作)。
window.showResetLayoutModal = function showResetLayoutModal() {
const modal = document.getElementById('resetLayoutModal');
if (!modal) return;
modal.classList.add('show');
};
window.hideResetLayoutModal = function hideResetLayoutModal() {
const modal = document.getElementById('resetLayoutModal');
if (!modal) return;
modal.classList.remove('show');
};
window.confirmResetLayout = function confirmResetLayout() {
try {
if (window.layoutManager && typeof window.layoutManager.resetLayout === 'function') {
window.layoutManager.resetLayout();
}
} finally {
window.hideResetLayoutModal();
}
};
// ESC 关闭确认框(不影响主业务)
document.addEventListener('keydown', (e) => {
if (!e || e.key !== 'Escape') return;
const modal = document.getElementById('resetLayoutModal');
if (!modal || !modal.classList.contains('show')) return;
window.hideResetLayoutModal();
});
try {
if (typeof window.LayoutManager !== 'function') return;
const layoutManager = new window.LayoutManager();
// 标记布局系统已启用:用于禁用 main.css 中的移动端历史侧栏滑入样式(避免折叠指示器被移出屏幕)
// 放在 init() 之前,减少窄屏下的闪烁/错位窗口期。
try {
if (document && document.documentElement && document.documentElement.classList) {
document.documentElement.classList.add('layout-system-enabled');
}
} catch (_) {
// ignore
}
layoutManager.init();
// 暴露到全局,便于调试(后续状态管理/折叠等也复用同一实例)
window.layoutManager = layoutManager;
} catch (e) {
// 初始化失败不应影响主业务功能
console.warn('[LayoutManager] init failed:', e);
}
});
File diff suppressed because it is too large. Load diff
+320
View File
@@ -0,0 +1,320 @@
/**
* StateManager - 布局状态持久化管理器
*
* 职责(按文档逐步实现):
* - 将 LayoutState 保存到 localStorage(多用户隔离 key)
* - 从 localStorage 加载并验证 LayoutState
* - 提供版本迁移能力(migrate)
*
* 兼容性说明:
* - 浏览器环境:通过 <script> 引入后,StateManager 挂载到 window.StateManager。
* - 测试/Node 环境:如果存在 module.exports,则导出 StateManager 以便 Jest 直接 require。
*/
(function (global) {
'use strict';
const CURRENT_VERSION = '1.1';
const STORAGE_KEY_PREFIX = 'outlook_layout_state_';
class StateManager {
/**
* 安全获取 localStorage(某些浏览器/隐私模式下访问会抛错)
* @returns {Storage|null}
*/
getStorage() {
try {
if (!global) return null;
// 访问 localStorage 属性本身也可能抛错,因此需要 try/catch
return global.localStorage || null;
} catch (_) {
return null;
}
}
/**
* 生成 localStorage key
* @param {string} userId
* @returns {string}
*/
getStorageKey(userId) {
const safeUserId = userId && String(userId).trim() ? String(userId).trim() : 'guest';
return `${STORAGE_KEY_PREFIX}${safeUserId}`;
}
/**
* 保存状态到 localStorage
* @param {string} userId
* @param {Record<string, any>} state
* @returns {boolean} 是否保存成功
*/
save(userId, state) {
const storage = this.getStorage();
if (!storage) {
try {
// eslint-disable-next-line no-console
console.warn('[StateManager] localStorage not available, using default layout');
} catch (_) {
// ignore
}
return false;
}
const payload = {
...state,
version: state && state.version ? state.version : CURRENT_VERSION,
userId: state && state.userId ? state.userId : userId,
timestamp: Date.now()
};
if (!this.validate(payload)) {
try {
// eslint-disable-next-line no-console
console.warn('[StateManager] invalid state, skip saving');
} catch (_) {
// ignore
}
return false;
}
const key = this.getStorageKey(userId);
const json = JSON.stringify(payload);
try {
storage.setItem(key, json);
return true;
} catch (e) {
// QuotaExceededError:清理旧数据后重试(TDD 7.3)
if (this.isQuotaExceededError(e)) {
try {
// eslint-disable-next-line no-console
console.warn('[StateManager] quota exceeded, clearing old states and retrying');
} catch (_) {
// ignore
}
try {
this.clearOldStates(key);
storage.setItem(key, json);
return true;
} catch (e2) {
try {
// eslint-disable-next-line no-console
console.warn('[StateManager] save retry failed:', e2);
} catch (_) {
// ignore
}
return false;
}
}
// localStorage 不可用等:降级为默认布局
try {
// eslint-disable-next-line no-console
console.warn('[StateManager] save failed:', e);
} catch (_) {
// ignore
}
return false;
}
}
/**
* 从 localStorage 加载状态
* @param {string} userId
* @returns {Record<string, any>|null}
*/
load(userId) {
const storage = this.getStorage();
if (!storage) {
try {
// eslint-disable-next-line no-console
console.warn('[StateManager] localStorage not available, using default layout');
} catch (_) {
// ignore
}
return null;
}
const key = this.getStorageKey(userId);
let raw = null;
try {
raw = storage.getItem(key);
} catch (e) {
try {
// eslint-disable-next-line no-console
console.warn('[StateManager] load failed (storage error):', e);
} catch (_) {
// ignore
}
return null;
}
if (!raw) return null;
let parsed = null;
try {
parsed = JSON.parse(raw);
} catch (e) {
// JSON 损坏:清除并降级为默认布局(TDD 7.3)
try {
this.clear(userId);
} catch (_) {
// ignore
}
try {
// eslint-disable-next-line no-console
console.error('[StateManager] load failed (invalid json):', e);
} catch (_) {
// ignore
}
return null;
}
const migrated = this.migrate(parsed);
if (!this.validate(migrated)) {
try {
this.clear(userId);
} catch (_) {
// ignore
}
try {
// eslint-disable-next-line no-console
console.warn('[StateManager] invalid state, cleared and fallback to default');
} catch (_) {
// ignore
}
return null;
}
return migrated;
}
/**
* 清除保存的状态
* @param {string} userId
* @returns {void}
*/
clear(userId) {
try {
const storage = this.getStorage();
if (!storage) return;
const key = this.getStorageKey(userId);
storage.removeItem(key);
} catch (_) {
// ignore
}
}
/**
* 判断是否为 QuotaExceededError(不同浏览器实现不同)
* @param {any} error
* @returns {boolean}
*/
isQuotaExceededError(error) {
if (!error) return false;
const name = typeof error.name === 'string' ? error.name : '';
if (name === 'QuotaExceededError') return true;
if (name === 'NS_ERROR_DOM_QUOTA_REACHED') return true;
const code = typeof error.code === 'number' ? error.code : null;
// 22/1014:常见的 quota 相关 code(Safari/Firefox)
return code === 22 || code === 1014;
}
/**
* 清理旧的布局状态(用于 QuotaExceededError 降级处理)
* @param {string} excludeKey 当前写入 key(避免误删正在保存的 key)
* @returns {void}
*/
clearOldStates(excludeKey) {
const storage = this.getStorage();
if (!storage) return;
const keys = [];
try {
for (let i = 0; i < storage.length; i += 1) {
const k = storage.key(i);
if (!k) continue;
if (!k.startsWith(STORAGE_KEY_PREFIX)) continue;
if (excludeKey && k === excludeKey) continue;
keys.push(k);
}
} catch (_) {
// ignore
}
// 按 timestamp 由旧到新清理(尽量保留最近的偏好)
const items = keys.map((k) => {
let ts = 0;
try {
const raw = storage.getItem(k);
if (raw) {
const parsed = JSON.parse(raw);
ts = parsed && typeof parsed.timestamp === 'number' ? parsed.timestamp : 0;
}
} catch (_) {
ts = 0;
}
return { key: k, ts };
});
items.sort((a, b) => a.ts - b.ts);
items.forEach((item) => {
try {
storage.removeItem(item.key);
} catch (_) {
// ignore
}
});
}
/**
* 校验状态数据格式
* @param {Record<string, any>} state
* @returns {boolean}
*/
validate(state) {
if (!state || typeof state !== 'object') return false;
if (!state.version || !state.panels) return false;
const panels = state.panels;
for (const panelType of ['groups', 'accounts', 'emails']) {
const panel = panels[panelType];
if (!panel || typeof panel !== 'object') return false;
if (!panel.width || !/^\d+px$/.test(panel.width)) return false;
if (typeof panel.collapsed !== 'boolean') return false;
}
return true;
}
/**
* 版本迁移(当前仅保证字段齐全;更复杂迁移在后续版本扩展)
* @param {Record<string, any>} state
* @returns {Record<string, any>}
*/
migrate(state) {
if (!state || typeof state !== 'object') return state;
// 若缺少 version,认为是旧数据,补齐为当前版本
if (!state.version) {
return { ...state, version: CURRENT_VERSION };
}
// 已是当前版本则直接返回
if (state.version === CURRENT_VERSION) return state;
// 其他版本:暂做“兼容性兜底”,保留原结构并标记为当前版本
return { ...state, version: CURRENT_VERSION };
}
}
// 浏览器环境挂载
global.StateManager = StateManager;
// Jest/Node 环境导出
if (typeof module !== 'undefined' && module.exports) {
module.exports = StateManager;
}
})(typeof window !== 'undefined' ? window : globalThis);
+93 -62
View File
@@ -8,6 +8,8 @@
<!-- DOMPurify for XSS protection -->
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<!-- 可调整布局系统:需在 main.css 之后引入以覆盖旧布局样式 -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}">
</head>
<body>
@@ -37,6 +39,14 @@
<button class="navbar-btn" onclick="showRefreshModal()">全量刷新Token</button>
<button class="navbar-btn" onclick="showExportModal()">导出邮箱</button>
<button class="navbar-btn" onclick="showSettingsModal()">⚙️ 设置</button>
<button class="navbar-btn reset-layout-btn" onclick="showResetLayoutModal()"
aria-label="恢复默认布局" title="恢复默认布局">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="1 4 1 10 7 10"></polyline>
<path d="M3.51 15a9 9 0 102.13-9.36L1 10"></path>
</svg>
</button>
<button class="navbar-btn" onclick="logout()" style="color: #dc3545;">退出登录</button>
</div>
</nav>
@@ -44,18 +54,27 @@
<!-- 主容器 -->
<div class="main-container">
<!-- 分组面板 -->
<div class="group-panel" id="groupPanel">
<div class="group-panel resizable-panel" id="groupPanel" data-panel="groups">
<div class="panel-header">
<span class="panel-title">分组</span>
<button class="panel-action-btn" onclick="showAddGroupModal()" title="添加分组">+</button>
<div style="display: flex; align-items: center; gap: 4px;">
<button class="panel-action-btn" onclick="showAddGroupModal()" title="添加分组">+</button>
<button class="panel-action-btn collapse-btn" data-panel="groups" aria-label="折叠分组面板"
aria-expanded="true" title="折叠分组面板">
←
</button>
</div>
</div>
<div class="group-list" id="groupList">
<!-- 分组列表将通过 JS 动态加载 -->
</div>
<div class="resizer" role="separator" aria-orientation="vertical" aria-label="调整分组面板宽度"
aria-valuenow="200" aria-valuemin="150" aria-valuemax="400" tabindex="0" data-resizer="groups">
</div>
</div>
<!-- 邮箱账号面板 -->
<div class="account-panel" id="accountPanel">
<div class="account-panel resizable-panel" id="accountPanel" data-panel="accounts">
<div class="account-panel-header">
<div class="account-panel-title" id="accountPanelTitle">
<span class="group-color" id="currentGroupColor" style="background-color: #666"></span>
@@ -71,6 +90,10 @@
d="M8 2a.75.75 0 01.75.75v4.5h4.5a.75.75 0 010 1.5h-4.5v4.5a.75.75 0 01-1.5 0v-4.5h-4.5a.75.75 0 010-1.5h4.5v-4.5A.75.75 0 018 2z" />
</svg>
</button>
<button class="panel-action-btn collapse-btn" data-panel="accounts" aria-label="折叠账号面板"
aria-expanded="true" title="折叠账号面板">
←
</button>
</div>
</div>
<div class="search-container">
@@ -120,74 +143,82 @@
<div class="account-panel-footer">
<button class="add-account-btn" onclick="showAddAccountModal()">+ 导入邮箱</button>
</div>
<div class="resizer" role="separator" aria-orientation="vertical" aria-label="调整账号面板宽度"
aria-valuenow="260" aria-valuemin="180" aria-valuemax="500" tabindex="0" data-resizer="accounts">
</div>
</div>
<!-- 内容区域 -->
<div class="content-area">
<!-- 邮件列表面板 -->
<div class="email-list-panel" id="emailListPanel">
<div class="email-list-header">
<div
style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
<div class="folder-tabs" id="folderTabs" style="display: none;">
<button class="folder-tab active" data-folder="inbox" onclick="switchFolder('inbox')">
📨 收件箱
</button>
<button class="folder-tab" data-folder="junkemail" onclick="switchFolder('junkemail')">
⚠️ 垃圾邮件
</button>
</div>
<button class="refresh-btn" onclick="refreshEmails()">获取邮件</button>
<!-- 邮件列表面板 -->
<div class="email-list-panel resizable-panel" id="emailListPanel" data-panel="emails">
<div class="email-list-header">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
<div class="folder-tabs" id="folderTabs" style="display: none;">
<button class="folder-tab active" data-folder="inbox" onclick="switchFolder('inbox')">
📨 收件箱
</button>
<button class="folder-tab" data-folder="junkemail" onclick="switchFolder('junkemail')">
⚠️ 垃圾邮件
</button>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<h2 style="margin: 0; font-size: 16px; font-weight: 600;">
邮件
<span class="email-count" id="emailCount"></span>
</h2>
<span class="method-tag" id="methodTag" style="display:none;"></span>
<button class="refresh-btn" onclick="refreshEmails()">获取邮件</button>
<button class="panel-action-btn collapse-btn" data-panel="emails" aria-label="折叠邮件列表面板"
aria-expanded="true" title="折叠邮件列表面板">
←
</button>
</div>
</div>
<div class="email-list" id="emailList">
<div class="empty-state">
<div class="empty-state-icon">📬</div>
<div class="empty-state-text">请从左侧选择一个邮箱账号</div>
</div>
</div>
<!-- Email Batch Action Bar -->
<div id="emailBatchActionBar"
style="display: none; padding: 8px 16px; border-top: 1px solid #e5e5e5; background: #f8f9fa; gap: 8px; align-items: center;">
<span id="emailSelectedCount" style="font-size: 12px; color: #666;">已选 0 项</span>
<div style="flex: 1;"></div>
<button class="batch-btn danger" onclick="confirmBatchDeleteEmails()">
🗑️ 删除
</button>
<div style="display: flex; align-items: center; gap: 8px;">
<h2 style="margin: 0; font-size: 16px; font-weight: 600;">
邮件
<span class="email-count" id="emailCount"></span>
</h2>
<span class="method-tag" id="methodTag" style="display:none;"></span>
</div>
</div>
<!-- 邮件详情面板 -->
<div class="email-detail-panel">
<div class="email-detail-toolbar" id="emailDetailToolbar" style="display:none;">
<button class="back-btn" onclick="showEmailList()">← 返回</button>
<button class="toggle-list-btn" onclick="toggleEmailList()">
<span id="toggleListText">隐藏列表</span>
</button>
<button class="fullscreen-btn" onclick="openFullscreenEmail()">🔍 全屏查看</button>
<label
style="display: flex; align-items: center; gap: 6px; font-size: 13px; margin-left: 12px; cursor: pointer;">
<input type="checkbox" id="trustEmailCheckbox" onchange="toggleTrustMode(this)">
<span style="user-select: none;">信任此邮件</span>
</label>
<div style="flex: 1;"></div>
<button class="batch-btn danger" onclick="confirmDeleteCurrentEmail()"
style="padding: 4px 8px; font-size: 12px;">
🗑️ 删除
</button>
<div class="email-list" id="emailList">
<div class="empty-state">
<div class="empty-state-icon">📬</div>
<div class="empty-state-text">请从左侧选择一个邮箱账号</div>
</div>
<div id="emailDetail">
<div class="empty-state">
<div class="empty-state-icon">📄</div>
<div class="empty-state-text">选择一封邮件查看详情</div>
</div>
</div>
<!-- Email Batch Action Bar -->
<div id="emailBatchActionBar"
style="display: none; padding: 8px 16px; border-top: 1px solid #e5e5e5; background: #f8f9fa; gap: 8px; align-items: center;">
<span id="emailSelectedCount" style="font-size: 12px; color: #666;">已选 0 项</span>
<div style="flex: 1;"></div>
<button class="batch-btn danger" onclick="confirmBatchDeleteEmails()">
🗑️ 删除
</button>
</div>
<div class="resizer" role="separator" aria-orientation="vertical" aria-label="调整邮件列表面板宽度"
aria-valuenow="380" aria-valuemin="280" aria-valuemax="600" tabindex="0" data-resizer="emails">
</div>
</div>
<!-- 邮件详情面板(不可折叠) -->
<div class="email-detail-panel">
<div class="email-detail-toolbar" id="emailDetailToolbar" style="display:none;">
<button class="back-btn" onclick="showEmailList()">← 返回</button>
<button class="toggle-list-btn" onclick="toggleEmailList()">
<span id="toggleListText">隐藏列表</span>
</button>
<button class="fullscreen-btn" onclick="openFullscreenEmail()">🔍 全屏查看</button>
<label
style="display: flex; align-items: center; gap: 6px; font-size: 13px; margin-left: 12px; cursor: pointer;">
<input type="checkbox" id="trustEmailCheckbox" onchange="toggleTrustMode(this)">
<span style="user-select: none;">信任此邮件</span>
</label>
<div style="flex: 1;"></div>
<button class="batch-btn danger" onclick="confirmDeleteCurrentEmail()"
style="padding: 4px 8px; font-size: 12px;">
🗑️ 删除
</button>
</div>
<div id="emailDetail">
<div class="empty-state">
<div class="empty-state-icon">📄</div>
<div class="empty-state-text">选择一封邮件查看详情</div>
</div>
</div>
</div>
+18
View File
@@ -667,6 +667,24 @@
</div>
</div>
<!-- 恢复默认布局确认模态框(可调整布局系统:F3) -->
<div class="modal" id="resetLayoutModal" onclick="if(event.target === this) hideResetLayoutModal()">
<div class="modal-content">
<div class="modal-header">
<h3>恢复默认布局</h3>
<button class="modal-close" onclick="hideResetLayoutModal()">&times;</button>
</div>
<div class="modal-body">
<p>确定要恢复到默认布局吗?</p>
<p class="text-muted">当前的面板宽度和折叠状态将被重置。</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="hideResetLayoutModal()">取消</button>
<button class="btn btn-primary" onclick="confirmResetLayout()">确定</button>
</div>
</div>
</div>
<!-- 消息提示 -->
<div class="toast" id="toast"></div>
+7
View File
@@ -3,3 +3,10 @@
<script src="{{ url_for('static', filename='js/features/temp_emails.js') }}"></script>
<script src="{{ url_for('static', filename='js/features/accounts.js') }}"></script>
<script src="{{ url_for('static', filename='js/features/emails.js') }}"></script>
<!-- 可调整布局系统(阶段四:状态持久化) -->
<script src="{{ url_for('static', filename='js/state-manager.js') }}"></script>
<!-- 可调整布局系统(阶段二/三:拖动调整 + 折叠) -->
<script src="{{ url_for('static', filename='js/layout-manager.js') }}"></script>
<script src="{{ url_for('static', filename='js/layout-bootstrap.js') }}"></script>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+289
View File
@@ -0,0 +1,289 @@
# 布局系统测试运行指南
## 测试概述
本目录包含可调整布局系统的完整测试套件,包括单元测试、集成测试、性能测试和可访问性测试。
## 目录结构
```
tests/layout-system/
├── jest.config.js # Jest 配置文件
├── setup.js # 测试环境设置
├── test-utils.js # 测试辅助工具
├── README.md # 本文件
├── unit/ # 单元测试
│ ├── layout-manager.test.js
│ └── state-manager.test.js
├── integration/ # 集成测试
│ ├── drag-resize.test.js
│ ├── panel-collapse.test.js
│ ├── reset-layout.test.js
│ ├── state-error-handling.test.js
│ ├── state-load.test.js
│ ├── state-multi-user.test.js
│ ├── state-save.test.js
│ ├── window-adapt.test.js
│ └── keyboard-resizer.test.js
├── performance/ # 性能测试
│ └── (待添加)
└── accessibility/ # 可访问性测试
└── (待添加)
```
## 前置要求
### 安装依赖
```bash
# 安装 Jest 和相关依赖
npm install --save-dev jest @jest/globals jest-environment-jsdom
# 或使用 yarn
yarn add --dev jest @jest/globals jest-environment-jsdom
```
### 配置 package.json
在项目根目录的 `package.json` 中添加测试脚本:
```json
{
"scripts": {
"test": "jest --config tests/layout-system/jest.config.js",
"test:watch": "jest --config tests/layout-system/jest.config.js --watch",
"test:coverage": "jest --config tests/layout-system/jest.config.js --coverage",
"test:unit": "jest --config tests/layout-system/jest.config.js tests/layout-system/unit",
"test:integration": "jest --config tests/layout-system/jest.config.js tests/layout-system/integration"
}
}
```
## 运行测试
### 运行所有测试
```bash
npm test
```
### 运行单元测试
```bash
npm run test:unit
```
### 运行集成测试
```bash
npm run test:integration
```
### 运行测试并生成覆盖率报告
```bash
npm run test:coverage
```
覆盖率报告将生成在 `tests/layout-system/coverage/` 目录。
### 监听模式(开发时使用)
```bash
npm run test:watch
```
## 测试用例说明
### 单元测试
#### StateManager 测试 (`unit/state-manager.test.js`)
测试状态持久化功能:
- ✅ TC-F4-001-P: 保存有效状态
- ✅ TC-F4-003-P: 加载有效状态
- ✅ TC-F4-004-P: 多用户布局隔离
- ✅ TC-F4-006-N: localStorage 不可用
- ✅ TC-F4-007-N: localStorage 数据损坏
- ✅ 版本迁移(v1.0 → v1.1)
- ✅ 数据验证
**运行单个测试文件:**
```bash
npx jest tests/layout-system/unit/state-manager.test.js
```
#### LayoutManager 测试 (`unit/layout-manager.test.js`)
测试布局管理核心功能:
- ✅ TC-F1-001-P: 计算拖动后的宽度
- ✅ TC-F1-002-B: 限制在最小宽度
- ✅ TC-F1-003-B: 限制在最大宽度
- ✅ TC-F2-001-P: 折叠面板
- ✅ TC-F2-002-P: 展开面板
- ✅ 更新 CSS 变量
- ✅ 切换面板状态
**运行单个测试文件:**
```bash
npx jest tests/layout-system/unit/layout-manager.test.js
```
### 集成测试
#### 拖动调整宽度测试 (`integration/drag-resize.test.js`)
测试完整的拖动流程:
- ✅ TC-F1-001-P: 完整的拖动流程
- ✅ TC-F1- 确保邮件详情面板至少 400px
- ✅ TC-F1-005-N: 快速拖动导致鼠标移出窗口
- ✅ TC-F1-006-PF: 拖动帧率测试(≥ 55fps)
**运行单个测试文件:**
```bash
npx jest tests/layout-system/integration/drag-resize.test.js
```
#### 面板折叠测试 (`integration/panel-collapse.test.js`)
测试完整的折叠/展开流程:
- ✅ TC-F2-001-P: 点击折叠按钮折叠面板
- ✅ TC-F2-002-P: 点击折叠指示条展开面板
- ✅ TC-F2-003-N: 尝试折叠所有面板
- ✅ TC-F2-004-P: 折叠后 resizer 不可见
- ✅ TC-F2-005-N: 折叠状态下尝试拖动
- ✅ 折叠动画性能测试(200ms ± 50ms)
**运行单个测试文件:**
```bash
npx jest tests/layout-system/integration/panel-collapse.test.js
```
## 测试覆盖率目标
根据 TD 文档的要求:
- **代码覆盖率**: > 80%
- 分支覆盖率: > 80%
- 函数覆盖率: > 80%
- 行覆盖率: > 80%
- 语句覆盖率: > 80%
- **功能覆盖率**: 100%
- F1 - 拖动调整宽度: 100%
- F2 - 面板折叠展开: 100%
- F3 - 恢复默认布局: 100%
- F4 - 状态持久化: 100%
- F5 - 窗口尺寸适配: 100%
- F6 - 键盘可访问性: 100%
## 测试数据
测试使用的数据定义在 `test-utils.js` 中:
- **用户 ID**: `test-user`, `user-a`, `user-b`, `guest`
- **面板宽度**:
- 分组面板: 150px (最小) ~ 400px (最大)
- 账号面板: 180px (最小) ~ 500px (最大)
- 邮件列表: 280px (最小) ~ 600px (最大)
- **窗口尺寸**: 1200px, 900px, 700px (断点)
## 调试测试
### 运行单个测试用例
```bash
npx jest -t "TC-F1-001-P"
```
### 查看详细输出
```bash
npx jest --verbose
```
### 调试模式
```bash
node --inspect-brk node_modules/.bin/jest --runInBand
```
然后在 Chrome 中打开 `chrome://inspect` 进行调试。
## 持续集成
### GitHub Actions 配置示例
```yaml
name: 布局系统测试
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Install dependencies
run: npm install
- name: Run tests
run: npm test
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
files: ./tests/layout-system/coverage/lcov.info
```
## 常见问题
### Q: 测试失败:localStorage is not defined
**A**: 确保 `setup.js` 文件正确配置了 localStorage mock。
### Q: 测试失败:requestAnimationFrame is not defined
**A**: 确保 `setup.js` 文件正确配置了 requestAnimationFrame mock。
### Q: 覆盖率不足 80%
**A**: 检查是否有未测试的代码分支,添加更多的边界测试用例。
### Q: 测试运行很慢
**A**: 使用 `--maxWorkers=4` 参数限制并发数,或使用 `--runInBand` 串行运行。
## 下一步计划
- [ ] 添加性能测试(帧率、内存占用)
- [ ] 添加可访问性测试(axe-core)
- [ ] 添加 E2E 测试(Playwright)
- [ ] 添加视觉回归测试
- [ ] 集成到 CI/CD 流程
## 参考文档
- [TD - 可调整布局系统](../../docs/TD/TD-可调整布局系统.md)
- [TDD - 可调整布局系统](.././TDD-可调整布局系统.md)
- [Jest 文档](https://jestjs.io/docs/getting-started)
- [Testing Library](https://testing-library.com/)
## 联系方式
如有问题,请联系测试团队或在 GitHub Issues 中提出。
+656
View File
@@ -0,0 +1,656 @@
<?xml version="1.0" encoding="UTF-8"?>
<coverage generated="1772174032201" clover="3.2.0">
<project timestamp="1772174032201" name="All files">
<metrics statements="644" coveredstatements="620" conditionals="539" coveredconditionals="445" methods="88" coveredmethods="88" elements="1271" coveredelements="1153" complexity="0" loc="644" ncloc="644" packages="1" files="2" classes="2"/>
<file name="layout-manager.js" path="E:\hushaokang\Data-code\outlookEmail\static\js\layout-manager.js">
<metrics statements="527" coveredstatements="509" conditionals="449" coveredconditionals="368" methods="75" coveredmethods="75"/>
<line num="17" count="10" type="stmt"/>
<line num="21" count="10" type="stmt"/>
<line num="23" count="10" type="stmt"/>
<line num="29" count="10" type="stmt"/>
<line num="43" count="10" type="stmt"/>
<line num="52" count="10" type="stmt"/>
<line num="64" count="80" type="stmt"/>
<line num="67" count="80" type="stmt"/>
<line num="70" count="80" type="stmt"/>
<line num="73" count="80" type="stmt"/>
<line num="76" count="80" type="stmt"/>
<line num="79" count="80" type="stmt"/>
<line num="82" count="80" type="stmt"/>
<line num="85" count="80" type="stmt"/>
<line num="88" count="80" type="stmt"/>
<line num="91" count="80" type="stmt"/>
<line num="94" count="80" type="stmt"/>
<line num="97" count="80" type="stmt"/>
<line num="100" count="80" type="stmt"/>
<line num="103" count="80" type="stmt"/>
<line num="106" count="80" type="stmt"/>
<line num="107" count="80" type="stmt"/>
<line num="108" count="80" type="stmt"/>
<line num="109" count="80" type="stmt"/>
<line num="110" count="80" type="stmt"/>
<line num="111" count="80" type="stmt"/>
<line num="112" count="80" type="stmt"/>
<line num="113" count="80" type="stmt"/>
<line num="114" count="80" type="stmt"/>
<line num="117" count="80" type="stmt"/>
<line num="119" count="80" type="stmt"/>
<line num="121" count="80" type="stmt"/>
<line num="129" count="80" type="stmt"/>
<line num="132" count="80" type="stmt"/>
<line num="133" count="80" type="stmt"/>
<line num="136" count="240" type="stmt"/>
<line num="140" count="80" type="stmt"/>
<line num="141" count="80" type="stmt"/>
<line num="142" count="240" type="stmt"/>
<line num="146" count="80" type="stmt"/>
<line num="147" count="240" type="stmt"/>
<line num="148" count="240" type="stmt"/>
<line num="151" count="240" type="stmt"/>
<line num="152" count="240" type="stmt"/>
<line num="156" count="80" type="stmt"/>
<line num="157" count="240" type="stmt"/>
<line num="158" count="240" type="stmt"/>
<line num="162" count="80" type="stmt"/>
<line num="165" count="80" type="stmt"/>
<line num="166" count="80" type="cond" truecount="2" falsecount="0"/>
<line num="168" count="79" type="stmt"/>
<line num="171" count="79" type="stmt"/>
<line num="175" count="80" type="cond" truecount="3" falsecount="1"/>
<line num="176" count="80" type="cond" truecount="2" falsecount="0"/>
<line num="177" count="70" type="stmt"/>
<line num="179" count="80" type="stmt"/>
<line num="180" count="80" type="stmt"/>
<line num="184" count="80" type="cond" truecount="3" falsecount="1"/>
<line num="185" count="80" type="cond" truecount="2" falsecount="0"/>
<line num="186" count="70" type="stmt"/>
<line num="188" count="80" type="stmt"/>
<line num="189" count="80" type="stmt"/>
<line num="193" count="80" type="stmt"/>
<line num="194" count="80" type="stmt"/>
<line num="197" count="80" type="stmt"/>
<line num="205" count="80" type="stmt"/>
<line num="206" count="80" type="cond" truecount="4" falsecount="0"/>
<line num="207" count="70" type="stmt"/>
<line num="211" count="10" type="cond" truecount="1" falsecount="1"/>
<line num="213" count="10" type="stmt"/>
<line num="214" count="10" type="cond" truecount="1" falsecount="1"/>
<line num="215" count="10" type="stmt"/>
<line num="219" count="0" type="stmt"/>
<line num="221" count="0" type="stmt"/>
<line num="226" count="0" type="stmt"/>
<line num="236" count="160" type="stmt"/>
<line num="238" count="160" type="stmt"/>
<line num="240" count="25" type="cond" truecount="2" falsecount="0"/>
<line num="241" count="25" type="stmt"/>
<line num="242" count="13" type="stmt"/>
<line num="243" count="13" type="stmt"/>
<line num="247" count="26" type="cond" truecount="2" falsecount="0"/>
<line num="248" count="1" type="stmt"/>
<line num="249" count="1" type="stmt"/>
<line num="251" count="26" type="stmt"/>
<line num="254" count="1" type="cond" truecount="1" falsecount="1"/>
<line num="255" count="1" type="stmt"/>
<line num="256" count="1" type="stmt"/>
<line num="268" count="30" type="stmt"/>
<line num="269" count="30" type="stmt"/>
<line num="270" count="90" type="stmt"/>
<line num="273" count="90" type="stmt"/>
<line num="275" count="90" type="cond" truecount="2" falsecount="1"/>
<line num="278" count="90" type="stmt"/>
<line num="281" count="90" type="stmt"/>
<line num="282" count="90" type="cond" truecount="1" falsecount="1"/>
<line num="286" count="90" type="cond" truecount="3" falsecount="0"/>
<line num="290" count="90" type="stmt"/>
<line num="293" count="30" type="stmt"/>
<line num="316" count="111" type="stmt"/>
<line num="317" count="111" type="stmt"/>
<line num="318" count="444" type="cond" truecount="4" falsecount="0"/>
<line num="319" count="3" type="stmt"/>
<line num="320" count="3" type="cond" truecount="1" falsecount="1"/>
<line num="324" count="111" type="cond" truecount="1" falsecount="1"/>
<line num="326" count="111" type="cond" truecount="3" falsecount="1"/>
<line num="327" count="111" type="stmt"/>
<line num="328" count="111" type="cond" truecount="1" falsecount="1"/>
<line num="331" count="111" type="cond" truecount="3" falsecount="1"/>
<line num="334" count="111" type="stmt"/>
<line num="335" count="111" type="cond" truecount="1" falsecount="1"/>
<line num="339" count="111" type="cond" truecount="1" falsecount="1"/>
<line num="342" count="111" type="cond" truecount="1" falsecount="1"/>
<line num="343" count="111" type="stmt"/>
<line num="344" count="111" type="cond" truecount="1" falsecount="1"/>
<line num="348" count="111" type="cond" truecount="1" falsecount="1"/>
<line num="349" count="111" type="stmt"/>
<line num="350" count="111" type="cond" truecount="2" falsecount="0"/>
<line num="355" count="108" type="stmt"/>
<line num="363" count="30" type="cond" truecount="3" falsecount="1"/>
<line num="365" count="30" type="cond" truecount="2" falsecount="0"/>
<line num="366" count="30" type="stmt"/>
<line num="367" count="30" type="stmt"/>
<line num="375" count="26" type="cond" truecount="1" falsecount="1"/>
<line num="378" count="26" type="stmt"/>
<line num="391" count="80" type="cond" truecount="3" falsecount="1"/>
<line num="393" count="80" type="cond" truecount="2" falsecount="0"/>
<line num="394" count="80" type="stmt"/>
<line num="395" count="80" type="cond" truecount="4" falsecount="0"/>
<line num="397" count="1" type="stmt"/>
<line num="398" count="1" type="stmt"/>
<line num="401" count="1" type="stmt"/>
<line num="402" count="1" type="stmt"/>
<line num="405" count="1" type="stmt"/>
<line num="414" count="81" type="cond" truecount="3" falsecount="1"/>
<line num="417" count="81" type="cond" truecount="3" falsecount="1"/>
<line num="418" count="0" type="stmt"/>
<line num="419" count="0" type="stmt"/>
<line num="422" count="81" type="stmt"/>
<line num="423" count="81" type="stmt"/>
<line num="424" count="81" type="stmt"/>
<line num="426" count="81" type="stmt"/>
<line num="427" count="67" type="stmt"/>
<line num="428" count="67" type="stmt"/>
<line num="434" count="81" type="cond" truecount="1" falsecount="1"/>
<line num="435" count="81" type="stmt"/>
<line num="437" count="0" type="stmt"/>
<line num="448" count="1" type="cond" truecount="3" falsecount="1"/>
<line num="451" count="1" type="cond" truecount="3" falsecount="1"/>
<line num="452" count="0" type="stmt"/>
<line num="453" count="0" type="stmt"/>
<line num="456" count="1" type="stmt"/>
<line num="457" count="1" type="stmt"/>
<line num="458" count="1" type="stmt"/>
<line num="461" count="1" type="stmt"/>
<line num="462" count="1" type="stmt"/>
<line num="463" count="1" type="stmt"/>
<line num="483" count="1" type="cond" truecount="3" falsecount="1"/>
<line num="484" count="1" type="stmt"/>
<line num="487" count="1" type="cond" truecount="2" falsecount="0"/>
<line num="488" count="1" type="stmt"/>
<line num="489" count="1" type="cond" truecount="3" falsecount="1"/>
<line num="490" count="1" type="stmt"/>
<line num="496" count="1" type="stmt"/>
<line num="498" count="1" type="stmt"/>
<line num="499" count="1" type="stmt"/>
<line num="500" count="3" type="stmt"/>
<line num="501" count="3" type="cond" truecount="1" falsecount="1"/>
<line num="504" count="3" type="stmt"/>
<line num="505" count="3" type="stmt"/>
<line num="508" count="3" type="stmt"/>
<line num="511" count="3" type="stmt"/>
<line num="515" count="1" type="stmt"/>
<line num="516" count="1" type="stmt"/>
<line num="519" count="1" type="stmt"/>
<line num="527" count="1" type="cond" truecount="3" falsecount="1"/>
<line num="528" count="1" type="cond" truecount="1" falsecount="1"/>
<line num="530" count="1" type="stmt"/>
<line num="531" count="3" type="stmt"/>
<line num="532" count="3" type="stmt"/>
<line num="533" count="3" type="cond" truecount="3" falsecount="1"/>
<line num="536" count="3" type="stmt"/>
<line num="537" count="3" type="cond" truecount="1" falsecount="1"/>
<line num="538" count="3" type="stmt"/>
<line num="539" count="3" type="stmt"/>
<line num="540" count="3" type="stmt"/>
<line num="541" count="3" type="stmt"/>
<line num="545" count="3" type="stmt"/>
<line num="548" count="3" type="cond" truecount="2" falsecount="0"/>
<line num="549" count="1" type="stmt"/>
<line num="550" count="1" type="stmt"/>
<line num="552" count="2" type="stmt"/>
<line num="553" count="2" type="stmt"/>
<line num="564" count="6" type="stmt"/>
<line num="565" count="6" type="cond" truecount="1" falsecount="1"/>
<line num="567" count="6" type="stmt"/>
<line num="568" count="6" type="cond" truecount="1" falsecount="1"/>
<line num="570" count="6" type="cond" truecount="2" falsecount="0"/>
<line num="571" count="1" type="stmt"/>
<line num="572" count="1" type="stmt"/>
<line num="573" count="1" type="stmt"/>
<line num="574" count="1" type="stmt"/>
<line num="575" count="1" type="stmt"/>
<line num="578" count="5" type="stmt"/>
<line num="579" count="5" type="stmt"/>
<line num="580" count="5" type="stmt"/>
<line num="581" count="5" type="stmt"/>
<line num="590" count="847" type="cond" truecount="4" falsecount="0"/>
<line num="592" count="322" type="stmt"/>
<line num="594" count="267" type="stmt"/>
<line num="596" count="257" type="stmt"/>
<line num="598" count="1" type="stmt"/>
<line num="607" count="8" type="cond" truecount="4" falsecount="0"/>
<line num="608" count="8" type="cond" truecount="4" falsecount="0"/>
<line num="609" count="8" type="cond" truecount="2" falsecount="0"/>
<line num="611" count="6" type="stmt"/>
<line num="612" count="6" type="stmt"/>
<line num="620" count="81" type="stmt"/>
<line num="622" count="81" type="stmt"/>
<line num="623" count="243" type="stmt"/>
<line num="624" count="243" type="cond" truecount="1" falsecount="1"/>
<line num="626" count="243" type="stmt"/>
<line num="629" count="243" type="cond" truecount="2" falsecount="0"/>
<line num="630" count="240" type="stmt"/>
<line num="631" count="240" type="stmt"/>
<line num="632" count="240" type="stmt"/>
<line num="633" count="240" type="stmt"/>
<line num="634" count="240" type="stmt"/>
<line num="635" count="240" type="stmt"/>
<line num="639" count="240" type="stmt"/>
<line num="644" count="240" type="stmt"/>
<line num="645" count="240" type="stmt"/>
<line num="646" count="240" type="stmt"/>
<line num="647" count="240" type="stmt"/>
<line num="649" count="240" type="stmt"/>
<line num="652" count="243" type="stmt"/>
<line num="653" count="243" type="stmt"/>
<line num="654" count="243" type="stmt"/>
<line num="655" count="243" type="stmt"/>
<line num="657" count="243" type="stmt"/>
<line num="666" count="5" type="cond" truecount="4" falsecount="0"/>
<line num="667" count="5" type="cond" truecount="4" falsecount="0"/>
<line num="668" count="5" type="cond" truecount="2" falsecount="0"/>
<line num="670" count="3" type="stmt"/>
<line num="671" count="3" type="stmt"/>
<line num="679" count="3" type="cond" truecount="2" falsecount="0"/>
<line num="680" count="2" type="stmt"/>
<line num="681" count="2" type="cond" truecount="4" falsecount="0"/>
<line num="683" count="1" type="stmt"/>
<line num="684" count="1" type="stmt"/>
<line num="695" count="138" type="stmt"/>
<line num="696" count="138" type="stmt"/>
<line num="698" count="138" type="stmt"/>
<line num="699" count="414" type="stmt"/>
<line num="700" count="414" type="stmt"/>
<line num="701" count="414" type="cond" truecount="3" falsecount="1"/>
<line num="703" count="414" type="cond" truecount="2" falsecount="0"/>
<line num="704" count="63" type="stmt"/>
<line num="705" count="63" type="stmt"/>
<line num="707" count="351" type="stmt"/>
<line num="717" count="138" type="stmt"/>
<line num="720" count="138" type="stmt"/>
<line num="721" count="138" type="cond" truecount="2" falsecount="0"/>
<line num="730" count="311" type="cond" truecount="4" falsecount="0"/>
<line num="732" count="139" type="stmt"/>
<line num="734" count="86" type="stmt"/>
<line num="736" count="85" type="stmt"/>
<line num="738" count="1" type="stmt"/>
<line num="748" count="53" type="cond" truecount="4" falsecount="0"/>
<line num="750" count="48" type="stmt"/>
<line num="752" count="2" type="stmt"/>
<line num="754" count="2" type="stmt"/>
<line num="756" count="1" type="stmt"/>
<line num="766" count="55" type="cond" truecount="5" falsecount="0"/>
<line num="767" count="53" type="cond" truecount="4" falsecount="0"/>
<line num="769" count="52" type="stmt"/>
<line num="770" count="52" type="stmt"/>
<line num="771" count="52" type="cond" truecount="2" falsecount="0"/>
<line num="773" count="51" type="stmt"/>
<line num="776" count="51" type="stmt"/>
<line num="777" count="51" type="cond" truecount="4" falsecount="0"/>
<line num="778" count="50" type="stmt"/>
<line num="782" count="51" type="stmt"/>
<line num="783" count="51" type="cond" truecount="1" falsecount="1"/>
<line num="784" count="51" type="stmt"/>
<line num="796" count="82" type="stmt"/>
<line num="797" count="246" type="stmt"/>
<line num="798" count="246" type="cond" truecount="1" falsecount="1"/>
<line num="800" count="246" type="cond" truecount="2" falsecount="0"/>
<line num="801" count="2" type="stmt"/>
<line num="803" count="244" type="stmt"/>
<line num="814" count="258" type="stmt"/>
<line num="815" count="258" type="cond" truecount="1" falsecount="1"/>
<line num="817" count="258" type="stmt"/>
<line num="818" count="258" type="stmt"/>
<line num="819" count="258" type="cond" truecount="1" falsecount="1"/>
<line num="820" count="258" type="stmt"/>
<line num="828" count="258" type="stmt"/>
<line num="829" count="258" type="stmt"/>
<line num="830" count="258" type="cond" truecount="3" falsecount="1"/>
<line num="832" count="258" type="stmt"/>
<line num="833" count="258" type="cond" truecount="1" falsecount="1"/>
<line num="834" count="258" type="stmt"/>
<line num="836" count="258" type="stmt"/>
<line num="847" count="44" type="stmt"/>
<line num="848" count="44" type="stmt"/>
<line num="849" count="44" type="cond" truecount="3" falsecount="1"/>
<line num="851" count="44" type="stmt"/>
<line num="852" count="44" type="stmt"/>
<line num="862" count="114" type="cond" truecount="2" falsecount="0"/>
<line num="868" count="1" type="stmt"/>
<line num="872" count="113" type="stmt"/>
<line num="885" count="46" type="stmt"/>
<line num="886" count="46" type="cond" truecount="2" falsecount="0"/>
<line num="889" count="44" type="cond" truecount="4" falsecount="0"/>
<line num="890" count="18" type="stmt"/>
<line num="891" count="18" type="cond" truecount="4" falsecount="0"/>
<line num="892" count="2" type="stmt"/>
<line num="893" count="2" type="stmt"/>
<line num="898" count="42" type="cond" truecount="2" falsecount="0"/>
<line num="899" count="26" type="stmt"/>
<line num="901" count="16" type="stmt"/>
<line num="904" count="42" type="stmt"/>
<line num="905" count="42" type="stmt"/>
<line num="906" count="42" type="stmt"/>
<line num="908" count="42" type="stmt"/>
<line num="909" count="42" type="cond" truecount="1" falsecount="1"/>
<line num="910" count="42" type="stmt"/>
<line num="911" count="42" type="stmt"/>
<line num="915" count="42" type="stmt"/>
<line num="919" count="42" type="stmt"/>
<line num="923" count="42" type="cond" truecount="2" falsecount="0"/>
<line num="924" count="16" type="stmt"/>
<line num="925" count="16" type="cond" truecount="3" falsecount="1"/>
<line num="926" count="16" type="stmt"/>
<line num="931" count="42" type="cond" truecount="2" falsecount="0"/>
<line num="932" count="16" type="stmt"/>
<line num="941" count="16" type="stmt"/>
<line num="942" count="16" type="cond" truecount="2" falsecount="0"/>
<line num="945" count="14" type="stmt"/>
<line num="947" count="14" type="stmt"/>
<line num="948" count="14" type="stmt"/>
<line num="949" count="14" type="stmt"/>
<line num="951" count="14" type="stmt"/>
<line num="952" count="14" type="cond" truecount="1" falsecount="1"/>
<line num="953" count="14" type="stmt"/>
<line num="954" count="14" type="stmt"/>
<line num="958" count="14" type="stmt"/>
<line num="962" count="14" type="stmt"/>
<line num="964" count="14" type="cond" truecount="4" falsecount="0"/>
<line num="965" count="9" type="stmt"/>
<line num="970" count="14" type="cond" truecount="2" falsecount="0"/>
<line num="971" count="9" type="stmt"/>
<line num="981" count="21" type="stmt"/>
<line num="982" count="21" type="stmt"/>
<line num="983" count="63" type="stmt"/>
<line num="984" count="63" type="cond" truecount="1" falsecount="1"/>
<line num="985" count="63" type="cond" truecount="2" falsecount="0"/>
<line num="986" count="56" type="stmt"/>
<line num="989" count="21" type="stmt"/>
<line num="998" count="3" type="stmt"/>
<line num="999" count="3" type="cond" truecount="3" falsecount="1"/>
<line num="1000" count="3" type="stmt"/>
<line num="1001" count="3" type="stmt"/>
<line num="1007" count="0" type="stmt"/>
<line num="1008" count="0" type="cond" truecount="0" falsecount="4"/>
<line num="1009" count="0" type="stmt"/>
<line num="1021" count="12" type="stmt"/>
<line num="1022" count="12" type="cond" truecount="2" falsecount="0"/>
<line num="1024" count="10" type="cond" truecount="2" falsecount="0"/>
<line num="1025" count="2" type="stmt"/>
<line num="1027" count="8" type="stmt"/>
<line num="1035" count="9" type="cond" truecount="1" falsecount="1"/>
<line num="1036" count="0" type="stmt"/>
<line num="1037" count="0" type="stmt"/>
<line num="1039" count="9" type="stmt"/>
<line num="1052" count="89" type="cond" truecount="3" falsecount="1"/>
<line num="1054" count="89" type="stmt"/>
<line num="1055" count="89" type="cond" truecount="3" falsecount="1"/>
<line num="1057" count="89" type="stmt"/>
<line num="1058" count="89" type="stmt"/>
<line num="1059" count="89" type="stmt"/>
<line num="1061" count="89" type="cond" truecount="2" falsecount="0"/>
<line num="1062" count="6" type="stmt"/>
<line num="1064" count="83" type="stmt"/>
<line num="1067" count="89" type="cond" truecount="2" falsecount="0"/>
<line num="1068" count="4" type="stmt"/>
<line num="1070" count="85" type="stmt"/>
<line num="1073" count="89" type="cond" truecount="2" falsecount="0"/>
<line num="1074" count="3" type="stmt"/>
<line num="1076" count="86" type="stmt"/>
<line num="1085" count="13" type="stmt"/>
<line num="1086" count="13" type="cond" truecount="1" falsecount="1"/>
<line num="1089" count="13" type="cond" truecount="4" falsecount="0"/>
<line num="1092" count="12" type="cond" truecount="4" falsecount="0"/>
<line num="1094" count="11" type="stmt"/>
<line num="1102" count="254" type="stmt"/>
<line num="1103" count="254" type="cond" truecount="1" falsecount="1"/>
<line num="1105" count="254" type="cond" truecount="4" falsecount="0"/>
<line num="1107" count="253" type="cond" truecount="2" falsecount="0"/>
<line num="1108" count="253" type="cond" truecount="2" falsecount="0"/>
<line num="1110" count="5" type="cond" truecount="3" falsecount="1"/>
<line num="1111" count="5" type="stmt"/>
<line num="1120" count="4" type="cond" truecount="1" falsecount="1"/>
<line num="1122" count="4" type="stmt"/>
<line num="1123" count="4" type="cond" truecount="3" falsecount="1"/>
<line num="1125" count="4" type="stmt"/>
<line num="1126" count="4" type="cond" truecount="3" falsecount="1"/>
<line num="1127" count="4" type="cond" truecount="1" falsecount="1"/>
<line num="1129" count="4" type="stmt"/>
<line num="1130" count="4" type="cond" truecount="1" falsecount="1"/>
<line num="1131" count="4" type="cond" truecount="3" falsecount="1"/>
<line num="1133" count="4" type="cond" truecount="2" falsecount="0"/>
<line num="1134" count="4" type="cond" truecount="2" falsecount="0"/>
<line num="1136" count="4" type="stmt"/>
<line num="1138" count="4" type="stmt"/>
<line num="1139" count="4" type="stmt"/>
<line num="1140" count="4" type="stmt"/>
<line num="1143" count="4" type="cond" truecount="3" falsecount="1"/>
<line num="1144" count="4" type="stmt"/>
<line num="1154" count="16" type="cond" truecount="5" falsecount="0"/>
<line num="1156" count="15" type="stmt"/>
<line num="1157" count="15" type="cond" truecount="4" falsecount="0"/>
<line num="1158" count="15" type="cond" truecount="2" falsecount="0"/>
<line num="1160" count="14" type="stmt"/>
<line num="1161" count="14" type="cond" truecount="2" falsecount="0"/>
<line num="1163" count="13" type="cond" truecount="2" falsecount="0"/>
<line num="1165" count="11" type="stmt"/>
<line num="1168" count="11" type="cond" truecount="1" falsecount="1"/>
<line num="1169" count="11" type="stmt"/>
<line num="1172" count="11" type="stmt"/>
<line num="1173" count="11" type="stmt"/>
<line num="1174" count="11" type="stmt"/>
<line num="1175" count="11" type="stmt"/>
<line num="1176" count="11" type="cond" truecount="3" falsecount="1"/>
<line num="1177" count="11" type="stmt"/>
<line num="1179" count="11" type="stmt"/>
<line num="1180" count="11" type="stmt"/>
<line num="1181" count="11" type="stmt"/>
<line num="1183" count="11" type="stmt"/>
<line num="1184" count="11" type="stmt"/>
<line num="1186" count="11" type="stmt"/>
<line num="1187" count="11" type="stmt"/>
<line num="1195" count="40" type="cond" truecount="5" falsecount="0"/>
<line num="1197" count="39" type="stmt"/>
<line num="1199" count="39" type="cond" truecount="2" falsecount="0"/>
<line num="1200" count="38" type="stmt"/>
<line num="1201" count="30" type="stmt"/>
<line num="1202" count="30" type="cond" truecount="4" falsecount="1"/>
<line num="1204" count="30" type="stmt"/>
<line num="1205" count="30" type="stmt"/>
<line num="1211" count="30" type="stmt"/>
<line num="1214" count="0" type="stmt"/>
<line num="1216" count="0" type="stmt"/>
<line num="1220" count="0" type="stmt"/>
<line num="1229" count="12" type="cond" truecount="2" falsecount="0"/>
<line num="1232" count="11" type="cond" truecount="5" falsecount="0"/>
<line num="1233" count="8" type="stmt"/>
<line num="1234" count="8" type="stmt"/>
<line num="1236" count="8" type="stmt"/>
<line num="1237" count="8" type="stmt"/>
<line num="1243" count="8" type="stmt"/>
<line num="1245" count="0" type="stmt"/>
<line num="1247" count="0" type="stmt"/>
<line num="1254" count="11" type="stmt"/>
<line num="1255" count="11" type="stmt"/>
<line num="1256" count="11" type="stmt"/>
<line num="1258" count="11" type="stmt"/>
<line num="1259" count="11" type="stmt"/>
<line num="1260" count="11" type="stmt"/>
<line num="1261" count="11" type="stmt"/>
<line num="1264" count="11" type="cond" truecount="3" falsecount="1"/>
<line num="1265" count="11" type="stmt"/>
<line num="1267" count="11" type="stmt"/>
<line num="1268" count="11" type="cond" truecount="1" falsecount="1"/>
<line num="1269" count="11" type="stmt"/>
<line num="1273" count="11" type="cond" truecount="3" falsecount="1"/>
<line num="1274" count="11" type="stmt"/>
<line num="1287" count="46" type="stmt"/>
<line num="1288" count="46" type="stmt"/>
<line num="1290" count="46" type="stmt"/>
<line num="1291" count="46" type="stmt"/>
<line num="1294" count="46" type="stmt"/>
<line num="1297" count="46" type="stmt"/>
<line num="1298" count="46" type="stmt"/>
<line num="1299" count="46" type="cond" truecount="3" falsecount="1"/>
<line num="1300" count="46" type="stmt"/>
<line num="1301" count="46" type="stmt"/>
<line num="1302" count="46" type="cond" truecount="1" falsecount="1"/>
<line num="1303" count="46" type="stmt"/>
<line num="1304" count="46" type="stmt"/>
<line num="1307" count="46" type="stmt"/>
<line num="1316" count="49" type="stmt"/>
<line num="1317" count="49" type="stmt"/>
<line num="1318" count="147" type="cond" truecount="2" falsecount="0"/>
<line num="1319" count="98" type="stmt"/>
<line num="1320" count="98" type="stmt"/>
<line num="1322" count="49" type="stmt"/>
<line num="1330" count="48" type="cond" truecount="2" falsecount="0"/>
<line num="1331" count="47" type="cond" truecount="1" falsecount="1"/>
<line num="1334" count="47" type="cond" truecount="5" falsecount="0"/>
<line num="1335" count="1" type="stmt"/>
<line num="1338" count="47" type="cond" truecount="4" falsecount="0"/>
<line num="1339" count="46" type="stmt"/>
<line num="1341" count="1" type="stmt"/>
<line num="1349" count="46" type="stmt"/>
<line num="1352" count="46" type="stmt"/>
<line num="1353" count="46" type="cond" truecount="1" falsecount="1"/>
<line num="1363" count="116" type="stmt"/>
<line num="1365" count="116" type="cond" truecount="3" falsecount="0"/>
<line num="1368" count="116" type="stmt"/>
<line num="1369" count="116" type="cond" truecount="2" falsecount="0"/>
<line num="1371" count="3" type="cond" truecount="2" falsecount="0"/>
<line num="1372" count="2" type="cond" truecount="1" falsecount="1"/>
<line num="1375" count="2" type="cond" truecount="5" falsecount="0"/>
<line num="1376" count="1" type="stmt"/>
<line num="1378" count="1" type="cond" truecount="3" falsecount="1"/>
<line num="1379" count="1" type="stmt"/>
<line num="1383" count="1" type="stmt"/>
<line num="1392" count="657" type="cond" truecount="4" falsecount="0"/>
<line num="1393" count="470" type="stmt"/>
<line num="1394" count="470" type="cond" truecount="2" falsecount="0"/>
<line num="1395" count="468" type="stmt"/>
<line num="1396" count="468" type="cond" truecount="2" falsecount="0"/>
<line num="1401" count="10" type="stmt"/>
<line num="1404" count="10" type="cond" truecount="3" falsecount="1"/>
<line num="1405" count="10" type="stmt"/>
</file>
<file name="state-manager.js" path="E:\hushaokang\Data-code\outlookEmail\static\js\state-manager.js">
<metrics statements="117" coveredstatements="111" conditionals="90" coveredconditionals="77" methods="13" coveredmethods="13"/>
<line num="14" count="11" type="stmt"/>
<line num="17" count="11" type="stmt"/>
<line num="18" count="11" type="stmt"/>
<line num="26" count="127" type="stmt"/>
<line num="27" count="127" type="cond" truecount="1" falsecount="1"/>
<line num="29" count="127" type="cond" truecount="2" falsecount="0"/>
<line num="31" count="1" type="stmt"/>
<line num="41" count="132" type="cond" truecount="4" falsecount="0"/>
<line num="42" count="132" type="stmt"/>
<line num="52" count="35" type="stmt"/>
<line num="53" count="35" type="cond" truecount="2" falsecount="0"/>
<line num="54" count="1" type="stmt"/>
<line num="56" count="1" type="stmt"/>
<line num="60" count="1" type="stmt"/>
<line num="63" count="34" type="stmt"/>
<line num="70" count="34" type="cond" truecount="2" falsecount="0"/>
<line num="71" count="1" type="stmt"/>
<line num="73" count="1" type="stmt"/>
<line num="77" count="1" type="stmt"/>
<line num="80" count="33" type="stmt"/>
<line num="81" count="33" type="stmt"/>
<line num="83" count="33" type="stmt"/>
<line num="84" count="33" type="stmt"/>
<line num="85" count="31" type="stmt"/>
<line num="88" count="2" type="cond" truecount="2" falsecount="0"/>
<line num="89" count="1" type="stmt"/>
<line num="91" count="1" type="stmt"/>
<line num="95" count="1" type="stmt"/>
<line num="96" count="1" type="stmt"/>
<line num="97" count="1" type="stmt"/>
<line num="98" count="1" type="stmt"/>
<line num="100" count="0" type="stmt"/>
<line num="102" count="0" type="stmt"/>
<line num="106" count="0" type="stmt"/>
<line num="111" count="1" type="stmt"/>
<line num="113" count="1" type="stmt"/>
<line num="117" count="1" type="stmt"/>
<line num="127" count="86" type="stmt"/>
<line num="128" count="86" type="cond" truecount="1" falsecount="1"/>
<line num="129" count="0" type="stmt"/>
<line num="131" count="0" type="stmt"/>
<line num="135" count="0" type="stmt"/>
<line num="138" count="86" type="stmt"/>
<line num="140" count="86" type="stmt"/>
<line num="141" count="86" type="stmt"/>
<line num="142" count="86" type="stmt"/>
<line num="144" count="3" type="stmt"/>
<line num="146" count="3" type="stmt"/>
<line num="150" count="3" type="stmt"/>
<line num="153" count="83" type="cond" truecount="2" falsecount="0"/>
<line num="155" count="6" type="stmt"/>
<line num="156" count="6" type="stmt"/>
<line num="157" count="6" type="stmt"/>
<line num="160" count="2" type="stmt"/>
<line num="161" count="2" type="stmt"/>
<line num="165" count="2" type="stmt"/>
<line num="167" count="2" type="stmt"/>
<line num="171" count="2" type="stmt"/>
<line num="174" count="4" type="stmt"/>
<line num="175" count="4" type="cond" truecount="2" falsecount="0"/>
<line num="176" count="1" type="stmt"/>
<line num="177" count="1" type="stmt"/>
<line num="181" count="1" type="stmt"/>
<line num="183" count="1" type="stmt"/>
<line num="187" count="1" type="stmt"/>
<line num="190" count="3" type="stmt"/>
<line num="199" count="5" type="stmt"/>
<line num="200" count="5" type="stmt"/>
<line num="201" count="5" type="cond" truecount="1" falsecount="1"/>
<line num="202" count="5" type="stmt"/>
<line num="203" count="5" type="stmt"/>
<line num="215" count="8" type="cond" truecount="2" falsecount="0"/>
<line num="216" count="7" type="cond" truecount="2" falsecount="0"/>
<line num="217" count="7" type="cond" truecount="2" falsecount="0"/>
<line num="218" count="5" type="cond" truecount="2" falsecount="0"/>
<line num="219" count="4" type="cond" truecount="2" falsecount="0"/>
<line num="221" count="4" type="cond" truecount="2" falsecount="0"/>
<line num="230" count="1" type="stmt"/>
<line num="231" count="1" type="cond" truecount="1" falsecount="1"/>
<line num="233" count="1" type="stmt"/>
<line num="234" count="1" type="stmt"/>
<line num="235" count="1" type="stmt"/>
<line num="236" count="4" type="stmt"/>
<line num="237" count="4" type="cond" truecount="1" falsecount="1"/>
<line num="238" count="4" type="cond" truecount="2" falsecount="0"/>
<line num="239" count="3" type="cond" truecount="4" falsecount="0"/>
<line num="240" count="2" type="stmt"/>
<line num="247" count="1" type="stmt"/>
<line num="248" count="2" type="stmt"/>
<line num="249" count="2" type="stmt"/>
<line num="250" count="2" type="stmt"/>
<line num="251" count="2" type="cond" truecount="1" falsecount="1"/>
<line num="252" count="2" type="stmt"/>
<line num="253" count="1" type="cond" truecount="3" falsecount="1"/>
<line num="256" count="1" type="stmt"/>
<line num="258" count="2" type="stmt"/>
<line num="261" count="1" type="stmt"/>
<line num="263" count="1" type="stmt"/>
<line num="264" count="2" type="stmt"/>
<line num="265" count="2" type="stmt"/>
<line num="278" count="43" type="cond" truecount="4" falsecount="0"/>
<line num="279" count="42" type="cond" truecount="4" falsecount="0"/>
<line num="281" count="41" type="stmt"/>
<line num="282" count="41" type="stmt"/>
<line num="283" count="115" type="stmt"/>
<line num="284" count="115" type="cond" truecount="3" falsecount="1"/>
<line num="285" count="115" type="cond" truecount="4" falsecount="0"/>
<line num="286" count="112" type="cond" truecount="2" falsecount="0"/>
<line num="289" count="37" type="stmt"/>
<line num="298" count="6" type="cond" truecount="3" falsecount="1"/>
<line num="301" count="6" type="cond" truecount="2" falsecount="0"/>
<line num="302" count="1" type="stmt"/>
<line num="306" count="5" type="cond" truecount="2" falsecount="0"/>
<line num="309" count="1" type="stmt"/>
<line num="314" count="11" type="stmt"/>
<line num="317" count="11" type="cond" truecount="3" falsecount="1"/>
<line num="318" count="11" type="stmt"/>
</file>
</project>
</coverage>
@@ -0,0 +1,3 @@
{"E:\\hushaokang\\Data-code\\outlookEmail\\static\\js\\layout-manager.js": {"path":"E:\\hushaokang\\Data-code\\outlookEmail\\static\\js\\layout-manager.js","statementMap":{"0":{"start":{"line":17,"column":0},"end":{"line":1407,"column":56}},"1":{"start":{"line":21,"column":35},"end":{"line":21,"column":39}},"2":{"start":{"line":23,"column":35},"end":{"line":23,"column":39}},"3":{"start":{"line":29,"column":30},"end":{"line":33,"column":3}},"4":{"start":{"line":43,"column":37},"end":{"line":47,"column":3}},"5":{"start":{"line":52,"column":41},"end":{"line":56,"column":3}},"6":{"start":{"line":64,"column":6},"end":{"line":64,"column":30}},"7":{"start":{"line":67,"column":6},"end":{"line":67,"column":28}},"8":{"start":{"line":70,"column":6},"end":{"line":70,"column":30}},"9":{"start":{"line":73,"column":6},"end":{"line":73,"column":32}},"10":{"start":{"line":76,"column":6},"end":{"line":76,"column":34}},"11":{"start":{"line":79,"column":6},"end":{"line":79,"column":31}},"12":{"start":{"line":82,"column":6},"end":{"line":82,"column":32}},"13":{"start":{"line":85,"column":6},"end":{"line":85,"column":31}},"14":{"start":{"line":88,"column":6},"end":{"line":88,"column":33}},"15":{"start":{"line":91,"column":6},"end":{"line":91,"column":35}},"16":{"start":{"line":94,"column":6},"end":{"line":94,"column":22}},"17":{"start":{"line":97,"column":6},"end":{"line":97,"column":26}},"18":{"start":{"line":100,"column":6},"end":{"line":100,"column":21}},"19":{"start":{"line":103,"column":6},"end":{"line":103,"column":30}},"20":{"start":{"line":106,"column":6},"end":{"line":106,"column":56}},"21":{"start":{"line":107,"column":6},"end":{"line":107,"column":46}},"22":{"start":{"line":108,"column":6},"end":{"line":108,"column":54}},"23":{"start":{"line":109,"column":6},"end":{"line":109,"column":68}},"24":{"start":{"line":110,"column":6},"end":{"line":110,"column":64}},"25":{"start":{"line":111,"column":6},"end":{"line":111,"column":68}},"26":{"start":{"line":112,"column":6},"end":{"line":112,"column":58}},"27":{"start":{"line":113,"column":6},"end":{"line":113,"column":64}},"28":{"start":{"line":114,"column":6},"end":{"line":114,"column":60}},"29":{"start":{"line":117,"column":6},"end":{"line":117,"column":52}},"30":{"start":{"line":119,"column":6},"end":{"line":119,"column":70}},"31":{"start":{"line":119,"column":47},"end":{"line":119,"column":63}},"32":{"start":{"line":121,"column":6},"end":{"line":121,"column":87}},"33":{"start":{"line":121,"column":55},"end":{"line":121,"column":80}},"34":{"start":{"line":129,"column":6},"end":{"line":129,"column":65}},"35":{"start":{"line":132,"column":6},"end":{"line":132,"column":26}},"36":{"start":{"line":133,"column":6},"end":{"line":137,"column":11}},"37":{"start":{"line":136,"column":10},"end":{"line":136,"column":54}},"38":{"start":{"line":140,"column":6},"end":{"line":140,"column":28}},"39":{"start":{"line":141,"column":6},"end":{"line":143,"column":9}},"40":{"start":{"line":142,"column":8},"end":{"line":142,"column":60}},"41":{"start":{"line":146,"column":6},"end":{"line":153,"column":9}},"42":{"start":{"line":147,"column":8},"end":{"line":147,"column":70}},"43":{"start":{"line":148,"column":8},"end":{"line":148,"column":67}},"44":{"start":{"line":151,"column":8},"end":{"line":151,"column":71}},"45":{"start":{"line":152,"column":8},"end":{"line":152,"column":68}},"46":{"start":{"line":156,"column":6},"end":{"line":159,"column":9}},"47":{"start":{"line":157,"column":8},"end":{"line":157,"column":64}},"48":{"start":{"line":158,"column":8},"end":{"line":158,"column":61}},"49":{"start":{"line":162,"column":6},"end":{"line":162,"column":39}},"50":{"start":{"line":165,"column":21},"end":{"line":165,"column":37}},"51":{"start":{"line":166,"column":6},"end":{"line":172,"column":7}},"52":{"start":{"line":168,"column":8},"end":{"line":168,"column":39}},"53":{"start":{"line":171,"column":8},"end":{"line":171,"column":47}},"54":{"start":{"line":175,"column":6},"end":{"line":181,"column":7}},"55":{"start":{"line":176,"column":8},"end":{"line":178,"column":9}},"56":{"start":{"line":177,"column":10},"end":{"line":177,"column":81}},"57":{"start":{"line":179,"column":8},"end":{"line":179,"column":58}},"58":{"start":{"line":180,"column":8},"end":{"line":180,"column":76}},"59":{"start":{"line":184,"column":6},"end":{"line":190,"column":7}},"60":{"start":{"line":185,"column":8},"end":{"line":187,"column":9}},"61":{"start":{"line":186,"column":10},"end":{"line":186,"column":75}},"62":{"start":{"line":188,"column":8},"end":{"line":188,"column":58}},"63":{"start":{"line":189,"column":8},"end":{"line":189,"column":70}},"64":{"start":{"line":193,"column":6},"end":{"line":195,"column":9}},"65":{"start":{"line":194,"column":8},"end":{"line":194,"column":34}},"66":{"start":{"line":197,"column":6},"end":{"line":197,"column":18}},"67":{"start":{"line":205,"column":6},"end":{"line":225,"column":7}},"68":{"start":{"line":206,"column":8},"end":{"line":208,"column":9}},"69":{"start":{"line":207,"column":10},"end"Line truncated
,"E:\\hushaokang\\Data-code\\outlookEmail\\static\\js\\state-manager.js": {"path":"E:\\hushaokang\\Data-code\\outlookEmail\\static\\js\\state-manager.js","statementMap":{"0":{"start":{"line":14,"column":0},"end":{"line":320,"column":56}},"1":{"start":{"line":17,"column":26},"end":{"line":17,"column":31}},"2":{"start":{"line":18,"column":29},"end":{"line":18,"column":52}},"3":{"start":{"line":26,"column":6},"end":{"line":32,"column":7}},"4":{"start":{"line":27,"column":8},"end":{"line":27,"column":33}},"5":{"start":{"line":27,"column":21},"end":{"line":27,"column":33}},"6":{"start":{"line":29,"column":8},"end":{"line":29,"column":43}},"7":{"start":{"line":31,"column":8},"end":{"line":31,"column":20}},"8":{"start":{"line":41,"column":25},"end":{"line":41,"column":90}},"9":{"start":{"line":42,"column":6},"end":{"line":42,"column":50}},"10":{"start":{"line":52,"column":22},"end":{"line":52,"column":39}},"11":{"start":{"line":53,"column":6},"end":{"line":61,"column":7}},"12":{"start":{"line":54,"column":8},"end":{"line":59,"column":9}},"13":{"start":{"line":56,"column":10},"end":{"line":56,"column":90}},"14":{"start":{"line":60,"column":8},"end":{"line":60,"column":21}},"15":{"start":{"line":63,"column":22},"end":{"line":68,"column":7}},"16":{"start":{"line":70,"column":6},"end":{"line":78,"column":7}},"17":{"start":{"line":71,"column":8},"end":{"line":76,"column":9}},"18":{"start":{"line":73,"column":10},"end":{"line":73,"column":68}},"19":{"start":{"line":77,"column":8},"end":{"line":77,"column":21}},"20":{"start":{"line":80,"column":18},"end":{"line":80,"column":44}},"21":{"start":{"line":81,"column":19},"end":{"line":81,"column":42}},"22":{"start":{"line":83,"column":6},"end":{"line":118,"column":7}},"23":{"start":{"line":84,"column":8},"end":{"line":84,"column":35}},"24":{"start":{"line":85,"column":8},"end":{"line":85,"column":20}},"25":{"start":{"line":88,"column":8},"end":{"line":108,"column":9}},"26":{"start":{"line":89,"column":10},"end":{"line":94,"column":11}},"27":{"start":{"line":91,"column":12},"end":{"line":91,"column":92}},"28":{"start":{"line":95,"column":10},"end":{"line":107,"column":11}},"29":{"start":{"line":96,"column":12},"end":{"line":96,"column":37}},"30":{"start":{"line":97,"column":12},"end":{"line":97,"column":39}},"31":{"start":{"line":98,"column":12},"end":{"line":98,"column":24}},"32":{"start":{"line":100,"column":12},"end":{"line":105,"column":13}},"33":{"start":{"line":102,"column":14},"end":{"line":102,"column":68}},"34":{"start":{"line":106,"column":12},"end":{"line":106,"column":25}},"35":{"start":{"line":111,"column":8},"end":{"line":116,"column":9}},"36":{"start":{"line":113,"column":10},"end":{"line":113,"column":57}},"37":{"start":{"line":117,"column":8},"end":{"line":117,"column":21}},"38":{"start":{"line":127,"column":22},"end":{"line":127,"column":39}},"39":{"start":{"line":128,"column":6},"end":{"line":136,"column":7}},"40":{"start":{"line":129,"column":8},"end":{"line":134,"column":9}},"41":{"start":{"line":131,"column":10},"end":{"line":131,"column":90}},"42":{"start":{"line":135,"column":8},"end":{"line":135,"column":20}},"43":{"start":{"line":138,"column":18},"end":{"line":138,"column":44}},"44":{"start":{"line":140,"column":16},"end":{"line":140,"column":20}},"45":{"start":{"line":141,"column":6},"end":{"line":151,"column":7}},"46":{"start":{"line":142,"column":8},"end":{"line":142,"column":35}},"47":{"start":{"line":144,"column":8},"end":{"line":149,"column":9}},"48":{"start":{"line":146,"column":10},"end":{"line":146,"column":73}},"49":{"start":{"line":150,"column":8},"end":{"line":150,"column":20}},"50":{"start":{"line":153,"column":6},"end":{"line":153,"column":28}},"51":{"start":{"line":153,"column":16},"end":{"line":153,"column":28}},"52":{"start":{"line":155,"column":19},"end":{"line":155,"column":23}},"53":{"start":{"line":156,"column":6},"end":{"line":172,"column":7}},"54":{"start":{"line":157,"column":8},"end":{"line":157,"column":33}},"55":{"start":{"line":160,"column":8},"end":{"line":164,"column":9}},"56":{"start":{"line":161,"column":10},"end":{"line":161,"column":29}},"57":{"start":{"line":165,"column":8},"end":{"line":170,"column":9}},"58":{"start":{"line":167,"column":10},"end":{"line":167,"column":73}},"59":{"start":{"line":171,"column":8},"end":{"line":171,"column":20}},"60":{"start":{"line":174,"column":23},"end":{"line":174,"column":43}},"61":{"start":{"line":175,"column":6},"end":{"line":188,"column":7}},"62":{"start":{"line":176,"column":8},"end":{"line":180,"column":9}},"63":{"start":{"line":177,"column":10},"end":{"line":177,"column":29}},"64":{"start":{"line":181,"column":8},"end":{"line":186,"column":9}},"65":{"start":{"line":183,"column":10},"end":{"line":183,"column":88}},"66":{"start":{"line":187,"column":8},"end":{"line":187,"column":20}},"67":{"start":{"line":190,"column":6},"end":{"line":190,"column":22}},"68":{"start":{"line":199,"column":6},"end":{"line":206,"column":7}},"69":{"start":{"line":200,"column":24},"end":{"line":200,Line truncated
}
@@ -0,0 +1,224 @@
body, html {
margin:0; padding: 0;
height: 100%;
}
body {
font-family: Helvetica Neue, Helvetica, Arial;
font-size: 14px;
color:#333;
}
.small { font-size: 12px; }
*, *:after, *:before {
-webkit-box-sizing:border-box;
-moz-box-sizing:border-box;
box-sizing:border-box;
}
h1 { font-size: 20px; margin: 0;}
h2 { font-size: 14px; }
pre {
font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
margin: 0;
padding: 0;
-moz-tab-size: 2;
-o-tab-size: 2;
tab-size: 2;
}
a { color:#0074D9; text-decoration:none; }
a:hover { text-decoration:underline; }
.strong { font-weight: bold; }
.space-top1 { padding: 10px 0 0 0; }
.pad2y { padding: 20px 0; }
.pad1y { padding: 10px 0; }
.pad2x { padding: 0 20px; }
.pad2 { padding: 20px; }
.pad1 { padding: 10px; }
.space-left2 { padding-left:55px; }
.space-right2 { padding-right:20px; }
.center { text-align:center; }
.clearfix { display:block; }
.clearfix:after {
content:'';
display:block;
height:0;
clear:both;
visibility:hidden;
}
.fl { float: left; }
@media only screen and (max-width:640px) {
.col3 { width:100%; max-width:100%; }
.hide-mobile { display:none!important; }
}
.quiet {
color: #7f7f7f;
color: rgba(0,0,0,0.5);
}
.quiet a { opacity: 0.7; }
.fraction {
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 10px;
color: #555;
background: #E8E8E8;
padding: 4px 5px;
border-radius: 3px;
vertical-align: middle;
}
div.path a:link, div.path a:visited { color: #333; }
table.coverage {
border-collapse: collapse;
margin: 10px 0 0 0;
padding: 0;
}
table.coverage td {
margin: 0;
padding: 0;
vertical-align: top;
}
table.coverage td.line-count {
text-align: right;
padding: 0 5px 0 20px;
}
table.coverage td.line-coverage {
text-align: right;
padding-right: 10px;
min-width:20px;
}
table.coverage td span.cline-any {
display: inline-block;
padding: 0 5px;
width: 100%;
}
.missing-if-branch {
display: inline-block;
margin-right: 5px;
border-radius: 3px;
position: relative;
padding: 0 4px;
background: #333;
color: yellow;
}
.skip-if-branch {
display: none;
margin-right: 10px;
position: relative;
padding: 0 4px;
background: #ccc;
color: white;
}
.missing-if-branch .typ, .skip-if-branch .typ {
color: inherit !important;
}
.coverage-summary {
border-collapse: collapse;
width: 100%;
}
.coverage-summary tr { border-bottom: 1px solid #bbb; }
.keyline-all { border: 1px solid #ddd; }
.coverage-summary td, .coverage-summary th { padding: 10px; }
.coverage-summary tbody { border: 1px solid #bbb; }
.coverage-summary td { border-right: 1px solid #bbb; }
.coverage-summary td:last-child { border-right: none; }
.coverage-summary th {
text-align: left;
font-weight: normal;
white-space: nowrap;
}
.coverage-summary th.file { border-right: none !important; }
.coverage-summary th.pct { }
.coverage-summary th.pic,
.coverage-summary th.abs,
.coverage-summary td.pct,
.coverage-summary td.abs { text-align: right; }
.coverage-summary td.file { white-space: nowrap; }
.coverage-summary td.pic { min-width: 120px !important; }
.coverage-summary tfoot td { }
.coverage-summary .sorter {
height: 10px;
width: 7px;
display: inline-block;
margin-left: 0.5em;
background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
}
.coverage-summary .sorted .sorter {
background-position: 0 -20px;
}
.coverage-summary .sorted-desc .sorter {
background-position: 0 -10px;
}
.status-line { height: 10px; }
/* yellow */
.cbranch-no { background: yellow !important; color: #111; }
/* dark red */
.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
.low .chart { border:1px solid #C21F39 }
.highlighted,
.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
background: #C21F39 !important;
}
/* medium red */
.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
/* light red */
.low, .cline-no { background:#FCE1E5 }
/* light green */
.high, .cline-yes { background:rgb(230,245,208) }
/* medium green */
.cstat-yes { background:rgb(161,215,106) }
/* dark green */
.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
.high .chart { border:1px solid rgb(77,146,33) }
/* dark yellow (gold) */
.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
.medium .chart { border:1px solid #f9cd0b; }
/* light yellow */
.medium { background: #fff4c2; }
.cstat-skip { background: #ddd; color: #111; }
.fstat-skip { background: #ddd; color: #111 !important; }
.cbranch-skip { background: #ddd !important; color: #111; }
span.cline-neutral { background: #eaeaea; }
.coverage-summary td.empty {
opacity: .5;
padding-top: 4px;
padding-bottom: 4px;
line-height: 1;
color: #888;
}
.cover-fill, .cover-empty {
display:inline-block;
height: 12px;
}
.chart {
line-height: 0;
}
.cover-empty {
background: white;
}
.cover-full {
border-right: none !important;
}
pre.prettyprint {
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
.com { color: #999 !important; }
.ignore-none { color: #999; font-weight: normal; }
.wrapper {
min-height: 100%;
height: auto !important;
height: 100%;
margin: 0 auto -48px;
}
.footer, .push {
height: 48px;
}
@@ -0,0 +1,87 @@
/* eslint-disable */
var jumpToCode = (function init() {
// Classes of code we would like to highlight in the file view
var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
// Elements to highlight in the file listing view
var fileListingElements = ['td.pct.low'];
// We don't want to select elements that are direct descendants of another match
var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
// Selector that finds elements on the page to which we can jump
var selector =
fileListingElements.join(', ') +
', ' +
notSelector +
missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
// The NodeList of matching elements
var missingCoverageElements = document.querySelectorAll(selector);
var currentIndex;
function toggleClass(index) {
missingCoverageElements
.item(currentIndex)
.classList.remove('highlighted');
missingCoverageElements.item(index).classList.add('highlighted');
}
function makeCurrent(index) {
toggleClass(index);
currentIndex = index;
missingCoverageElements.item(index).scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
});
}
function goToPrevious() {
var nextIndex = 0;
if (typeof currentIndex !== 'number' || currentIndex === 0) {
nextIndex = missingCoverageElements.length - 1;
} else if (missingCoverageElements.length > 1) {
nextIndex = currentIndex - 1;
}
makeCurrent(nextIndex);
}
function goToNext() {
var nextIndex = 0;
if (
typeof currentIndex === 'number' &&
currentIndex < missingCoverageElements.length - 1
) {
nextIndex = currentIndex + 1;
}
makeCurrent(nextIndex);
}
return function jump(event) {
if (
document.getElementById('fileSearch') === document.activeElement &&
document.activeElement != null
) {
// if we're currently focused on the search input, we don't want to navigate
return;
}
switch (event.which) {
case 78: // n
case 74: // j
goToNext();
break;
case 66: // b
case 75: // k
case 80: // p
goToPrevious();
break;
}
};
})();
window.addEventListener('keydown', jumpToCode);
Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

@@ -0,0 +1,131 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for All files</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="prettify.css" />
<link rel="stylesheet" href="base.css" />
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1>All files</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">91.83% </span>
<span class="quiet">Statements</span>
<span class='fraction'>664/723</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">82.56% </span>
<span class="quiet">Branches</span>
<span class='fraction'>445/539</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>88/88</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">96.27% </span>
<span class="quiet">Lines</span>
<span class='fraction'>620/644</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<div class="pad1">
<table class="coverage-summary">
<thead>
<tr>
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
</tr>
</thead>
<tbody><tr>
<td class="file high" data-value="layout-manager.js"><a href="layout-manager.js.html">layout-manager.js</a></td>
<td data-value="91.99" class="pic high">
<div class="chart"><div class="cover-fill" style="width: 91%"></div><div class="cover-empty" style="width: 9%"></div></div>
</td>
<td data-value="91.99" class="pct high">91.99%</td>
<td data-value="587" class="abs high">540/587</td>
<td data-value="81.95" class="pct high">81.95%</td>
<td data-value="449" class="abs high">368/449</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="75" class="abs high">75/75</td>
<td data-value="96.58" class="pct high">96.58%</td>
<td data-value="527" class="abs high">509/527</td>
</tr>
<tr>
<td class="file high" data-value="state-manager.js"><a href="state-manager.js.html">state-manager.js</a></td>
<td data-value="91.17" class="pic high">
<div class="chart"><div class="cover-fill" style="width: 91%"></div><div class="cover-empty" style="width: 9%"></div></div>
</td>
<td data-value="91.17" class="pct high">91.17%</td>
<td data-value="136" class="abs high">124/136</td>
<td data-value="85.55" class="pct high">85.55%</td>
<td data-value="90" class="abs high">77/90</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="13" class="abs high">13/13</td>
<td data-value="94.87" class="pct high">94.87%</td>
<td data-value="117" class="abs high">111/117</td>
</tr>
</tbody>
</table>
</div>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-02-27T06:33:52.071Z
</div>
<script src="prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="sorter.js"></script>
<script src="block-navigation.js"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
@@ -0,0 +1,2 @@
/* eslint-disable */
window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V<U;++V){var ae=Z[V];if(ae.ignoreCase){ac=true}else{if(/[a-z]/i.test(ae.source.replace(/\\u[0-9a-f]{4}|\\x[0-9a-f]{2}|\\[^ux]/gi,""))){S=true;ac=false;break}}}var Y={b:8,t:9,n:10,v:11,f:12,r:13};function ab(ah){var ag=ah.charCodeAt(0);if(ag!==92){return ag}var af=ah.charAt(1);ag=Y[af];if(ag){return ag}else{if("0"<=af&&af<="7"){return parseInt(ah.substring(1),8)}else{if(af==="u"||af==="x"){return parseInt(ah.substring(2),16)}else{return ah.charCodeAt(1)}}}}function T(af){if(af<32){return(af<16?"\\x0":"\\x")+af.toString(16)}var ag=String.fromCharCode(af);if(ag==="\\"||ag==="-"||ag==="["||ag==="]"){ag="\\"+ag}return ag}function X(am){var aq=am.substring(1,am.length-1).match(new RegExp("\\\\u[0-9A-Fa-f]{4}|\\\\x[0-9A-Fa-f]{2}|\\\\[0-3][0-7]{0,2}|\\\\[0-7]{1,2}|\\\\[\\s\\S]|-|[^-\\\\]","g"));var ak=[];var af=[];var ao=aq[0]==="^";for(var ar=ao?1:0,aj=aq.length;ar<aj;++ar){var ah=aq[ar];if(/\\[bdsw]/i.test(ah)){ak.push(ah)}else{var ag=ab(ah);var al;if(ar+2<aj&&"-"===aq[ar+1]){al=ab(aq[ar+2]);ar+=2}else{al=ag}af.push([ag,al]);if(!(al<65||ag>122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;ar<af.length;++ar){var at=af[ar];if(at[0]<=ap[1]+1){ap[1]=Math.max(ap[1],at[1])}else{ai.push(ap=at)}}var an=["["];if(ao){an.push("^")}an.push.apply(an,ak);for(var ar=0;ar<ai.length;++ar){var at=ai[ar];an.push(T(at[0]));if(at[1]>at[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak<ah;++ak){var ag=aj[ak];if(ag==="("){++am}else{if("\\"===ag.charAt(0)){var af=+ag.substring(1);if(af&&af<=am){an[af]=-1}}}}for(var ak=1;ak<an.length;++ak){if(-1===an[ak]){an[ak]=++ad}}for(var ak=0,am=0;ak<ah;++ak){var ag=aj[ak];if(ag==="("){++am;if(an[am]===undefined){aj[ak]="(?:"}}else{if("\\"===ag.charAt(0)){var af=+ag.substring(1);if(af&&af<=am){aj[ak]="\\"+an[am]}}}}for(var ak=0,am=0;ak<ah;++ak){if("^"===aj[ak]&&"^"!==aj[ak+1]){aj[ak]=""}}if(al.ignoreCase&&S){for(var ak=0;ak<ah;++ak){var ag=aj[ak];var ai=ag.charAt(0);Line truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

@@ -0,0 +1,210 @@
/* eslint-disable */
var addSorting = (function() {
'use strict';
var cols,
currentSort = {
index: 0,
desc: false
};
// returns the summary table element
function getTable() {
return document.querySelector('.coverage-summary');
}
// returns the thead element of the summary table
function getTableHeader() {
return getTable().querySelector('thead tr');
}
// returns the tbody element of the summary table
function getTableBody() {
return getTable().querySelector('tbody');
}
// returns the th element for nth column
function getNthColumn(n) {
return getTableHeader().querySelectorAll('th')[n];
}
function onFilterInput() {
const searchValue = document.getElementById('fileSearch').value;
const rows = document.getElementsByTagName('tbody')[0].children;
// Try to create a RegExp from the searchValue. If it fails (invalid regex),
// it will be treated as a plain text search
let searchRegex;
try {
searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
} catch (error) {
searchRegex = null;
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
let isMatch = false;
if (searchRegex) {
// If a valid regex was created, use it for matching
isMatch = searchRegex.test(row.textContent);
} else {
// Otherwise, fall back to the original plain text search
isMatch = row.textContent
.toLowerCase()
.includes(searchValue.toLowerCase());
}
row.style.display = isMatch ? '' : 'none';
}
}
// loads the search box
function addSearchBox() {
var template = document.getElementById('filterTemplate');
var templateClone = template.content.cloneNode(true);
templateClone.getElementById('fileSearch').oninput = onFilterInput;
template.parentElement.appendChild(templateClone);
}
// loads all columns
function loadColumns() {
var colNodes = getTableHeader().querySelectorAll('th'),
colNode,
cols = [],
col,
i;
for (i = 0; i < colNodes.length; i += 1) {
colNode = colNodes[i];
col = {
key: colNode.getAttribute('data-col'),
sortable: !colNode.getAttribute('data-nosort'),
type: colNode.getAttribute('data-type') || 'string'
};
cols.push(col);
if (col.sortable) {
col.defaultDescSort = col.type === 'number';
colNode.innerHTML =
colNode.innerHTML + '<span class="sorter"></span>';
}
}
return cols;
}
// attaches a data attribute to every tr element with an object
// of data values keyed by column name
function loadRowData(tableRow) {
var tableCols = tableRow.querySelectorAll('td'),
colNode,
col,
data = {},
i,
val;
for (i = 0; i < tableCols.length; i += 1) {
colNode = tableCols[i];
col = cols[i];
val = colNode.getAttribute('data-value');
if (col.type === 'number') {
val = Number(val);
}
data[col.key] = val;
}
return data;
}
// loads all row data
function loadData() {
var rows = getTableBody().querySelectorAll('tr'),
i;
for (i = 0; i < rows.length; i += 1) {
rows[i].data = loadRowData(rows[i]);
}
}
// sorts the table using the data for the ith column
function sortByIndex(index, desc) {
var key = cols[index].key,
sorter = function(a, b) {
a = a.data[key];
b = b.data[key];
return a < b ? -1 : a > b ? 1 : 0;
},
finalSorter = sorter,
tableBody = document.querySelector('.coverage-summary tbody'),
rowNodes = tableBody.querySelectorAll('tr'),
rows = [],
i;
if (desc) {
finalSorter = function(a, b) {
return -1 * sorter(a, b);
};
}
for (i = 0; i < rowNodes.length; i += 1) {
rows.push(rowNodes[i]);
tableBody.removeChild(rowNodes[i]);
}
rows.sort(finalSorter);
for (i = 0; i < rows.length; i += 1) {
tableBody.appendChild(rows[i]);
}
}
// removes sort indicators for current column being sorted
function removeSortIndicators() {
var col = getNthColumn(currentSort.index),
cls = col.className;
cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
col.className = cls;
}
// adds sort indicators for current column being sorted
function addSortIndicators() {
getNthColumn(currentSort.index).className += currentSort.desc
? ' sorted-desc'
: ' sorted';
}
// adds event listeners for all sorter widgets
function enableUI() {
var i,
el,
ithSorter = function ithSorter(i) {
var col = cols[i];
return function() {
var desc = col.defaultDescSort;
if (currentSort.index === i) {
desc = !currentSort.desc;
}
sortByIndex(i, desc);
removeSortIndicators();
currentSort.index = i;
currentSort.desc = desc;
addSortIndicators();
};
};
for (i = 0; i < cols.length; i += 1) {
if (cols[i].sortable) {
// add the click event handler on the th so users
// dont have to click on those tiny arrows
el = getNthColumn(i).querySelector('.sorter').parentElement;
if (el.addEventListener) {
el.addEventListener('click', ithSorter(i));
} else {
el.attachEvent('onclick', ithSorter(i));
}
}
}
}
// adds sorting functionality to the UI
return function() {
if (!getTable()) {
return;
}
cols = loadColumns();
loadData();
addSearchBox();
addSortIndicators();
enableUI();
};
})();
window.addEventListener('load', addSorting);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,112 @@
/**
* 集成测试 - 拖动调整宽度完整流程(TASK-02-002)
*
* 说明:
* - 该文件聚焦 TC-F1-001-P:从 mousedown 到 mouseup 的完整链路。
* - 其他边界/性能用例将在后续 TASK 中逐步补齐(避免影响阶段性迭代)。
*/
const LayoutManager = require('../../../static/js/layout-manager.js');
const { createLayoutDOM, simulateDrag, wait, getCSSVariable } = require('../test-utils.js');
describe('拖动调整宽度集成测试(TASK-02-002)', () => {
let layoutManager;
beforeEach(() => {
createLayoutDOM();
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1600
});
layoutManager = new LayoutManager();
layoutManager.init();
});
test('TC-F1-001-P: 完整的拖动流程', async () => {
const resizer = document.querySelector('[data-resizer="groups"]');
// 初始宽度
expect(getCSSVariable('--group-panel-width')).toBe('200px');
// 模拟拖动:从 200px 拖到 250px(+50)
simulateDrag(resizer, 200, 250);
// 等待 requestAnimationFrame 执行
await wait(50);
// 验证宽度已更新
expect(getCSSVariable('--group-panel-width')).toBe('250px');
});
test('TC-F1-005-N: 快速拖动导致鼠标移出窗口后应正常结束', async () => {
const resizer = document.querySelector('[data-resizer="groups"]');
// 开始拖动
resizer.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 0 })
);
expect(document.body.classList.contains('layout-resizing')).toBe(true);
// 模拟快速拖动到窗口外(以超大 clientX 表示)
document.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 5000 })
);
// 模拟窗口失焦(等价于鼠标移出/切到其他窗口)
window.dispatchEvent(new Event('blur'));
await wait(50);
// 应结束拖动并清理全局状态
expect(layoutManager.isResizing).toBe(false);
expect(document.body.classList.contains('layout-resizing')).toBe(false);
});
test('TC-F1-006-PF: 拖动更新频率应接近 60fps(基于 updatePanelWidth 调用间隔模拟)', () => {
jest.useFakeTimers();
try {
const resizer = document.querySelector('[data-resizer="groups"]');
const timestamps = [];
const originalUpdate = layoutManager.updatePanelWidth.bind(layoutManager);
jest
.spyOn(layoutManager, 'updatePanelWidth')
.mockImplementation((panel, width) => {
timestamps.push(performance.now());
return originalUpdate(panel, width);
});
resizer.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 0 })
);
// 模拟连续拖动(约 0.5s,使用 fake timers 保证稳定性)
for (let i = 0; i < 30; i++) {
document.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 200 + i })
);
jest.advanceTimersByTime(16);
}
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
jest.advanceTimersByTime(50);
// 至少应发生多次更新
expect(timestamps.length).toBeGreaterThanOrEqual(10);
// 计算平均 fps(忽略第一帧)
const deltas = [];
for (let i = 1; i < timestamps.length; i++) {
const delta = timestamps[i] - timestamps[i - 1];
if (delta > 0) deltas.push(delta);
}
const avgDelta = deltas.reduce((a, b) => a + b, 0) / deltas.length;
const avgFps = 1000 / avgDelta;
// 允许一定误差(jsdom + 定时器会有波动)
expect(avgFps).toBeGreaterThanOrEqual(55);
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,75 @@
/**
* 集成测试 - 键盘可访问性(F6)
*
* 覆盖:
* - TC-F6-002-P: 方向键调整宽度
* - TC-F6-003-P: Shift + 方向键快速调整
* - 键盘调整后触发 500ms 防抖保存(F6.1)
*/
const LayoutManager = require('../../../static/js/layout-manager.js');
const { createLayoutDOM, getCSSVariable, simulateKeyPress } = require('../test-utils.js');
describe('键盘可访问性集成测试(F6)', () => {
let layoutManager;
const storageKey = 'outlook_layout_state_guest';
beforeEach(() => {
jest.useFakeTimers();
createLayoutDOM();
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1600
});
layoutManager = new LayoutManager();
layoutManager.init();
jest.runOnlyPendingTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('TC-F6-002-P: 方向键调整宽度(10px)', () => {
const resizer = document.querySelector('[data-resizer="groups"]');
expect(getCSSVariable('--group-panel-width')).toBe('200px');
simulateKeyPress(resizer, 'ArrowRight');
expect(getCSSVariable('--group-panel-width')).toBe('210px');
expect(resizer.getAttribute('aria-valuenow')).toBe('210');
simulateKeyPress(resizer, 'ArrowLeft');
expect(getCSSVariable('--group-panel-width')).toBe('200px');
expect(resizer.getAttribute('aria-valuenow')).toBe('200');
});
test('TC-F6-003-P: Shift + 方向键快速调整(50px)', () => {
const resizer = document.querySelector('[data-resizer="groups"]');
expect(getCSSVariable('--group-panel-width')).toBe('200px');
simulateKeyPress(resizer, 'ArrowRight', true);
expect(getCSSVariable('--group-panel-width')).toBe('250px');
expect(resizer.getAttribute('aria-valuenow')).toBe('250');
});
test('键盘调整后应触发 500ms 防抖保存', () => {
const resizer = document.querySelector('[data-resizer="groups"]');
simulateKeyPress(resizer, 'ArrowRight');
// 500ms 内不应写入
expect(window.localStorage.getItem(storageKey)).toBeNull();
jest.advanceTimersByTime(499);
expect(window.localStorage.getItem(storageKey)).toBeNull();
// 500ms 到达后写入
jest.advanceTimersByTime(1);
const savedRaw = window.localStorage.getItem(storageKey);
expect(savedRaw).toBeTruthy();
const saved = JSON.parse(savedRaw);
expect(saved.panels.groups.width).toBe('210px');
});
});
@@ -0,0 +1,115 @@
/**
* 集成测试 - 面板折叠/展开完整流程
*
* 覆盖:TD 文档 F2 用例(TC-F2-001 ~ TC-F2-005)
*/
const LayoutManager = require('../../../static/js/layout-manager.js');
const { createLayoutDOM, getCSSVariable } = require('../test-utils.js');
describe('面板折叠/展开集成测试(阶段三)', () => {
let layoutManager;
beforeEach(() => {
createLayoutDOM();
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1600
});
layoutManager = new LayoutManager();
layoutManager.init();
});
test('TC-F2-001-P: 点击折叠按钮折叠面板', () => {
const panel = document.querySelector('[data-panel="groups"]');
const btn = panel.querySelector('.collapse-btn');
expect(getCSSVariable('--group-panel-width')).toBe('200px');
expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe(
'200px'
);
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
// 宽度变为 0(布局生效宽度),保存宽度不丢失
expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe(
'0px'
);
expect(getCSSVariable('--group-panel-width')).toBe('200px');
// 折叠状态与按钮状态更新
expect(panel.classList.contains('collapsed')).toBe(true);
expect(btn.textContent.trim()).toBe('→');
expect(btn.getAttribute('aria-expanded')).toBe('false');
expect(btn.getAttribute('aria-label')).toBe('展开分组面板');
// 指示器存在且可用于展开
const indicator = panel.querySelector('.collapsed-indicator[data-panel="groups"]');
expect(indicator).toBeTruthy();
expect(indicator.getAttribute('role')).toBe('button');
expect(indicator.getAttribute('tabindex')).toBe('0');
expect(indicator.getAttribute('aria-label')).toBe('展开分组面板');
});
test('TC-F2-002-P: 点击折叠指示条展开面板', () => {
const panel = document.querySelector('[data-panel="groups"]');
const btn = panel.querySelector('.collapse-btn');
const indicator = panel.querySelector('.collapsed-indicator[data-panel="groups"]');
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(panel.classList.contains('collapsed')).toBe(true);
expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe(
'0px'
);
indicator.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(panel.classList.contains('collapsed')).toBe(false);
expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe(
'200px'
);
expect(btn.textContent.trim()).toBe('←');
expect(btn.getAttribute('aria-expanded')).toBe('true');
expect(btn.getAttribute('aria-label')).toBe('折叠分组面板');
});
test('TC-F2-003-N: 尝试折叠所有面板应提示并阻止', () => {
// 先折叠另外两个面板(auto=true 绕过规则)
layoutManager.collapsePanel('accounts', true);
layoutManager.collapsePanel('emails', true);
const groupsPanel = document.querySelector('[data-panel="groups"]');
const groupsBtn = groupsPanel.querySelector('.collapse-btn');
const originalToast = window.showToast;
window.showToast = jest.fn();
try {
groupsBtn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(groupsPanel.classList.contains('collapsed')).toBe(false);
expect(window.showToast).toHaveBeenCalledWith('至少需要保持一个面板展开', 'warning');
} finally {
window.showToast = originalToast;
}
});
test('TC-F2-004-P/TC-F2-005-N: 折叠后 resizer 不可用且无法拖动', () => {
const panel = document.querySelector('[data-panel="groups"]');
const btn = panel.querySelector('.collapse-btn');
const resizer = document.querySelector('[data-resizer="groups"]');
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(panel.classList.contains('collapsed')).toBe(true);
// 尝试拖动(startResize 内部应直接返回)
resizer.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, cancelable: true, clientX: 200, button: 0 })
);
document.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX: 260 })
);
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
expect(layoutManager.isResizing).toBe(false);
});
});
@@ -0,0 +1,74 @@
/**
* 集成测试 - 恢复默认布局(F3)
*
* 覆盖:
* - TC-F3-001-P: 恢复默认布局(宽度/折叠状态恢复 + localStorage 清除 + 300ms 动画开关)
*/
const LayoutManager = require('../../../static/js/layout-manager.js');
const { createLayoutDOM, getCSSVariable } = require('../test-utils.js');
describe('恢复默认布局集成测试(F3)', () => {
let layoutManager;
const storageKey = 'outlook_layout_state_guest';
beforeEach(() => {
jest.useFakeTimers();
createLayoutDOM();
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1600
});
layoutManager = new LayoutManager();
layoutManager.init();
// 清掉 init() 里 requestAnimationFrame/withNoTransition 的残留计时器
jest.runOnlyPendingTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('TC-F3-001-P: resetLayout() 应恢复默认宽度、展开面板并清除 localStorage', () => {
const groupsPanel = document.querySelector('[data-panel="groups"]');
const accountsPanel = document.querySelector('[data-panel="accounts"]');
const emailsPanel = document.querySelector('[data-panel="emails"]');
// 先制造“非默认状态”并写入 localStorage
layoutManager.updatePanelWidth(groupsPanel, 250);
layoutManager.collapsePanel('accounts', false); // 手动折叠会立即保存
expect(window.localStorage.getItem(storageKey)).toBeTruthy();
// 再制造一个“尚未到期的保存防抖”,用于验证 reset 会 cancel
layoutManager.saveDebouncer.debounced();
layoutManager.resetLayout();
// 立即生效:localStorage 被清空
expect(window.localStorage.getItem(storageKey)).toBeNull();
// 默认宽度恢复
expect(getCSSVariable('--group-panel-width')).toBe('200px');
expect(getCSSVariable('--account-panel-width')).toBe('260px');
expect(getCSSVariable('--email-list-panel-width')).toBe('380px');
// 面板展开
expect(groupsPanel.classList.contains('collapsed')).toBe(false);
expect(accountsPanel.classList.contains('collapsed')).toBe(false);
expect(emailsPanel.classList.contains('collapsed')).toBe(false);
// 300ms 动画开关:class 先存在,随后移除
expect(document.documentElement.classList.contains('layout-resetting')).toBe(true);
jest.advanceTimersByTime(350);
expect(document.documentElement.classList.contains('layout-resetting')).toBe(false);
// 防抖保存应被取消:推进 500ms 不应重新写入
jest.advanceTimersByTime(1000);
expect(window.localStorage.getItem(storageKey)).toBeNull();
});
});
@@ -0,0 +1,101 @@
/**
* 集成测试 - 状态持久化错误处理(TASK-04-005)
*
* 覆盖:
* - TC-F4-006-N: localStorage 不可用
* - TC-F4-007-N: localStorage 数据损坏
*/
const LayoutManager = require('../../../static/js/layout-manager.js');
const { createLayoutDOM, getCSSVariable, simulateDrag } = require('../test-utils.js');
describe('状态持久化错误处理集成测试(TASK-04-005)', () => {
beforeEach(() => {
createLayoutDOM();
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1600
});
});
test('TC-F4-006-N: localStorage 不可用时应降级为默认布局且不影响拖动', () => {
const originalLocalStorage = window.localStorage;
// 模拟隐私模式/禁用 localStorage:访问/读写均抛错
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
getItem: () => {
throw new Error('localStorage disabled');
},
setItem: () => {
throw new Error('localStorage disabled');
},
removeItem: () => {
throw new Error('localStorage disabled');
},
key: () => null,
get length() {
return 0;
}
}
});
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
const layoutManager = new LayoutManager();
layoutManager.init();
// 拖动仍应生效(只是无法持久化)
jest.useFakeTimers();
try {
const resizer = document.querySelector('[data-resizer="groups"]');
simulateDrag(resizer, 200, 250);
expect(getCSSVariable('--group-panel-width')).toBe('250px');
// 即使等待 500ms,也不应抛异常
jest.advanceTimersByTime(600);
} finally {
jest.useRealTimers();
}
// “刷新页面”后应回到默认布局(无法持久化)
createLayoutDOM();
const layoutManager2 = new LayoutManager();
layoutManager2.init();
expect(getCSSVariable('--group-panel-width')).toBe('200px');
expect(getCSSVariable('--account-panel-width')).toBe('260px');
expect(getCSSVariable('--email-list-panel-width')).toBe('380px');
} finally {
warnSpy.mockRestore();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: originalLocalStorage
});
}
});
test('TC-F4-007-N: localStorage 数据损坏时应清除并使用默认布局', () => {
const storageKey = 'outlook_layout_state_guest';
window.localStorage.setItem(storageKey, '{bad json');
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
const layoutManager = new LayoutManager();
layoutManager.init();
// 应使用默认布局(createLayoutDOM 初始化的 CSS 变量)
expect(getCSSVariable('--group-panel-width')).toBe('200px');
expect(getCSSVariable('--account-panel-width')).toBe('260px');
expect(getCSSVariable('--email-list-panel-width')).toBe('380px');
// 损坏数据应被清除
expect(window.localStorage.getItem(storageKey)).toBeNull();
} finally {
errSpy.mockRestore();
}
});
});
@@ -0,0 +1,60 @@
/**
* 集成测试 - 状态加载(TASK-04-003)
*
* 覆盖:
* - TC-F4-003-P: 页面刷新后恢复状态
*/
const LayoutManager = require('../../../static/js/layout-manager.js');
const { createLayoutDOM, getCSSVariable } = require('../test-utils.js');
describe('状态加载集成测试(TASK-04-003)', () => {
const storageKey = 'outlook_layout_state_guest';
beforeEach(() => {
createLayoutDOM();
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1600
});
});
test('TC-F4-003-P: 页面刷新后状态恢复(宽度 + 折叠)', () => {
const state = {
version: '1.1',
userId: 'guest',
timestamp: Date.now(),
panels: {
groups: { width: '250px', collapsed: true },
accounts: { width: '300px', collapsed: false },
emails: { width: '420px', collapsed: false }
}
};
window.localStorage.setItem(storageKey, JSON.stringify(state));
const layoutManager = new LayoutManager();
layoutManager.init();
// 宽度恢复
expect(getCSSVariable('--group-panel-width')).toBe('250px');
expect(getCSSVariable('--account-panel-width')).toBe('300px');
expect(getCSSVariable('--email-list-panel-width')).toBe('420px');
// 折叠状态恢复(groups)
const groupsPanel = document.querySelector('[data-panel="groups"]');
const groupsBtn = groupsPanel.querySelector('.collapse-btn');
expect(groupsPanel.classList.contains('collapsed')).toBe(true);
expect(groupsPanel.dataset.autoCollapsed).toBeUndefined();
expect(groupsBtn.getAttribute('aria-expanded')).toBe('false');
expect(groupsBtn.textContent.trim()).toBe('→');
expect(groupsBtn.getAttribute('aria-label')).toBe('展开分组面板');
// 布局变量应同步(折叠列宽为 0)
expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe(
'0px'
);
});
});
@@ -0,0 +1,82 @@
/**
* 集成测试 - 多用户隔离(TASK-04-004)
*
* 覆盖:
* - TC-F4-004-P: 多用户布局隔离
*/
const LayoutManager = require('../../../static/js/layout-manager.js');
const { createLayoutDOM, getCSSVariable, simulateDrag } = require('../test-utils.js');
describe('多用户隔离集成测试(TASK-04-004)', () => {
beforeEach(() => {
createLayoutDOM();
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1600
});
});
test('TC-F4-004-P: 不同用户的布局应独立存储(save 使用 userId key)', () => {
jest.useFakeTimers();
try {
window.currentUserId = 'userA';
const layoutManager = new LayoutManager();
layoutManager.init();
const resizer = document.querySelector('[data-resizer="groups"]');
simulateDrag(resizer, 200, 260);
// 500ms 到达后写入 outlook_layout_state_userA
jest.advanceTimersByTime(500);
const savedRaw = window.localStorage.getItem('outlook_layout_state_userA');
expect(savedRaw).toBeTruthy();
const saved = JSON.parse(savedRaw);
expect(saved.userId).toBe('userA');
expect(saved.panels.groups.width).toBe('260px');
// 不应污染 guest key
expect(window.localStorage.getItem('outlook_layout_state_guest')).toBeNull();
} finally {
jest.useRealTimers();
delete window.currentUserId;
}
});
test('TC-F4-004-P: 用户切换后应加载各自的布局(userB 不应看到 userA 的布局)', () => {
// userA 保存一个自定义布局
window.currentUserId = 'userA';
window.localStorage.setItem(
'outlook_layout_state_userA',
JSON.stringify({
version: '1.1',
userId: 'userA',
timestamp: Date.now(),
panels: {
groups: { width: '250px', collapsed: false },
accounts: { width: '320px', collapsed: false },
emails: { width: '450px', collapsed: false }
}
})
);
// “切换用户”相当于换一个 userId 并重新进入页面
createLayoutDOM();
window.currentUserId = 'userB';
const layoutManager = new LayoutManager();
layoutManager.init();
// userB 无保存数据,应保持默认宽度(createLayoutDOM 初始化的 CSS 变量)
expect(getCSSVariable('--group-panel-width')).toBe('200px');
expect(getCSSVariable('--account-panel-width')).toBe('260px');
expect(getCSSVariable('--email-list-panel-width')).toBe('380px');
delete window.currentUserId;
});
});
@@ -0,0 +1,85 @@
/**
* 集成测试 - 状态保存(TASK-04-002)
*
* 覆盖:
* - TC-F4-001-P: 拖动后 500ms 保存状态
* - TC-F4-002-P: 折叠后立即保存状态
* - TC-F4-005-P: beforeunload 强制保存
*/
const LayoutManager = require('../../../static/js/layout-manager.js');
const { createLayoutDOM, simulateDrag } = require('../test-utils.js');
describe('状态保存集成测试(TASK-04-002)', () => {
let layoutManager;
const storageKey = 'outlook_layout_state_guest';
beforeEach(() => {
createLayoutDOM();
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1600
});
layoutManager = new LayoutManager();
layoutManager.init();
});
test('TC-F4-001-P: 拖动后 500ms 保存状态', () => {
jest.useFakeTimers();
try {
const resizer = document.querySelector('[data-resizer="groups"]');
simulateDrag(resizer, 200, 250);
// 500ms 内不应写入
expect(window.localStorage.getItem(storageKey)).toBeNull();
jest.advanceTimersByTime(499);
expect(window.localStorage.getItem(storageKey)).toBeNull();
// 500ms 到达后写入
jest.advanceTimersByTime(1);
const savedRaw = window.localStorage.getItem(storageKey);
expect(savedRaw).toBeTruthy();
const saved = JSON.parse(savedRaw);
expect(saved.version).toBe('1.1');
expect(saved.userId).toBe('guest');
expect(saved.panels.groups.width).toBe('250px');
expect(saved.panels.groups.collapsed).toBe(false);
} finally {
jest.useRealTimers();
}
});
test('TC-F4-002-P: 折叠后立即保存状态', () => {
const btn = document.querySelector('[data-panel="groups"] .collapse-btn');
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
const savedRaw = window.localStorage.getItem(storageKey);
expect(savedRaw).toBeTruthy();
const saved = JSON.parse(savedRaw);
expect(saved.panels.groups.collapsed).toBe(true);
});
test('TC-F4-005-P: 页面卸载时强制保存', () => {
jest.useFakeTimers();
try {
const resizer = document.querySelector('[data-resizer="groups"]');
simulateDrag(resizer, 200, 260);
// 防抖尚未到达时触发 beforeunload,应立即落盘
expect(window.localStorage.getItem(storageKey)).toBeNull();
window.dispatchEvent(new Event('beforeunload'));
const savedRaw = window.localStorage.getItem(storageKey);
expect(savedRaw).toBeTruthy();
const saved = JSON.parse(savedRaw);
expect(saved.panels.groups.width).toBe('260px');
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,125 @@
/**
* 集成测试 - 窗口尺寸适配(F5)
*
* 覆盖:
* - TC-F5-001-P: <1200px 自动折叠分组面板
* - TC-F5-002-P: <900px 自动折叠账号面板
* - TC-F5-003-P: <700px 自动折叠邮件列表面板
* - TC-F5-004-P: 窗口放大后自动恢复(仅恢复 autoCollapsed)
* - TC-F5-005-N: 用户手动折叠的面板不自动恢复
*/
const LayoutManager = require('../../../static/js/layout-manager.js');
const { createLayoutDOM, setWindowSize } = require('../test-utils.js');
describe('窗口尺寸适配集成测试(F5)', () => {
let layoutManager;
beforeEach(() => {
jest.useFakeTimers();
createLayoutDOM();
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1600
});
layoutManager = new LayoutManager();
layoutManager.init();
jest.runOnlyPendingTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('TC-F5-001-P: 窗口宽度 < 1200px 自动折叠分组面板', () => {
setWindowSize(1199, 800);
jest.advanceTimersByTime(200);
const groupsPanel = document.querySelector('[data-panel="groups"]');
expect(groupsPanel.classList.contains('collapsed')).toBe(true);
expect(groupsPanel.dataset.autoCollapsed).toBe('true');
});
test('TC-F5-002-P: 窗口宽度 < 900px 自动折叠账号面板', () => {
setWindowSize(899, 800);
jest.advanceTimersByTime(200);
const groupsPanel = document.querySelector('[data-panel="groups"]');
const accountsPanel = document.querySelector('[data-panel="accounts"]');
expect(groupsPanel.classList.contains('collapsed')).toBe(true);
expect(accountsPanel.classList.contains('collapsed')).toBe(true);
expect(accountsPanel.dataset.autoCollapsed).toBe('true');
});
test('TC-F5-003-P: 窗口宽度 < 700px 自动折叠邮件列表面板', () => {
setWindowSize(699, 800);
jest.advanceTimersByTime(200);
const groupsPanel = document.querySelector('[data-panel="groups"]');
const accountsPanel = document.querySelector('[data-panel="accounts"]');
const emailsPanel = document.querySelector('[data-panel="emails"]');
expect(groupsPanel.classList.contains('collapsed')).toBe(true);
expect(accountsPanel.classList.contains('collapsed')).toBe(true);
expect(emailsPanel.classList.contains('collapsed')).toBe(true);
expect(emailsPanel.dataset.autoCollapsed).toBe('true');
});
test('TC-F5-004-P: 窗口放大后自动恢复面板(仅恢复 autoCollapsed)', () => {
setWindowSize(699, 800);
jest.advanceTimersByTime(200);
const groupsPanel = document.querySelector('[data-panel="groups"]');
const accountsPanel = document.querySelector('[data-panel="accounts"]');
const emailsPanel = document.querySelector('[data-panel="emails"]');
expect(groupsPanel.classList.contains('collapsed')).toBe(true);
expect(accountsPanel.classList.contains('collapsed')).toBe(true);
expect(emailsPanel.classList.contains('collapsed')).toBe(true);
setWindowSize(1600, 900);
jest.advanceTimersByTime(200);
expect(groupsPanel.classList.contains('collapsed')).toBe(false);
expect(accountsPanel.classList.contains('collapsed')).toBe(false);
expect(emailsPanel.classList.contains('collapsed')).toBe(false);
expect(groupsPanel.dataset.autoCollapsed).not.toBe('true');
expect(accountsPanel.dataset.autoCollapsed).not.toBe('true');
expect(emailsPanel.dataset.autoCollapsed).not.toBe('true');
});
test('TC-F5-005-N: 用户手动折叠的面板不自动恢复', () => {
const groupsPanel = document.querySelector('[data-panel="groups"]');
// 手动折叠:不打 autoCollapsed
layoutManager.collapsePanel('groups', false);
expect(groupsPanel.classList.contains('collapsed')).toBe(true);
expect(groupsPanel.dataset.autoCollapsed).not.toBe('true');
// 小屏下不应把“手动折叠”改写成 autoCollapsed
setWindowSize(1199, 800);
jest.advanceTimersByTime(200);
expect(groupsPanel.dataset.autoCollapsed).not.toBe('true');
// 放大也不应恢复
setWindowSize(1600, 900);
jest.advanceTimersByTime(200);
expect(groupsPanel.classList.contains('collapsed')).toBe(true);
});
test('隐藏状态下的邮件列表不参与自动折叠/恢复(避免空白列回归)', () => {
const emailsPanel = document.querySelector('[data-panel="emails"]');
emailsPanel.classList.add('hidden');
setWindowSize(699, 800);
jest.advanceTimersByTime(200);
expect(emailsPanel.dataset.autoCollapsed).not.toBe('true');
setWindowSize(1600, 900);
jest.advanceTimersByTime(200);
expect(emailsPanel.dataset.autoCollapsed).not.toBe('true');
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* Jest 配置文件 - 布局系统测试
*/
module.exports = {
// 测试环境
testEnvironment: 'jsdom',
// 根目录
rootDir: '../../',
// 测试文件匹配模式
testMatch: [
'**/tests/layout-system/unit/**/*.test.js',
'**/tests/layout-system/integration/**/*.test.js'
],
// 覆盖率配置
collectCoverage: true,
coverageDirectory: 'tests/layout-system/coverage',
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
// 覆盖率收集范围
collectCoverageFrom: [
'static/js/layout-manager.js',
'static/js/state-manager.js',
'!**/node_modules/**',
'!**/tests/**'
],
// 设置超时时间
testTimeout: 10000,
// 详细输出
verbose: true,
// 模拟 localStorage
setupFilesAfterEnv: ['<rootDir>/tests/layout-system/setup.js']
};
+113
View File
@@ -0,0 +1,113 @@
/**
* Jest 测试环境设置
* 模拟浏览器 API 和 DOM 环境
*/
// 模拟 localStorage
class LocalStorageMock {
constructor() {
this.store = {};
}
clear() {
this.store = {};
}
getItem(key) {
return this.store[key] || null;
}
setItem(key, value) {
this.store[key] = String(value);
}
removeItem(key) {
delete this.store[key];
}
get length() {
return Object.keys(this.store).length;
}
key(index) {
const keys = Object.keys(this.store);
return keys[index] || null;
}
}
global.localStorage = new LocalStorageMock();
// jsdom 环境下 window.localStorage 可能是独立实例;这里统一指向 mock,避免跨用例污染
try {
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: global.localStorage
});
}
} catch (_) {
// ignore
}
// 模拟主应用的 toast(避免 LayoutManager.notify() 退化到 window.alert,触发 jsdom 的 not implemented 报错)
global.showToast = () => {};
try {
if (typeof window !== 'undefined') {
window.showToast = global.showToast;
}
} catch (_) {
// ignore
}
// 模拟 requestAnimationFrame
global.requestAnimationFrame = (callback) => {
return setTimeout(callback, 16);
};
global.cancelAnimationFrame = (id) => {
clearTimeout(id);
};
// 模拟 performance.now
if (!global.performance) {
global.performance = {};
}
global.performance.now = () => Date.now();
// 模拟 CSS.supports
if (!global.CSS) {
global.CSS = {};
}
global.CSS.supports = (property, value) => {
// 简单模拟,假设支持所有现代 CSS 特性
return true;
};
// 模拟 getComputedStyle
global.getComputedStyle = (element) => {
return {
getPropertyValue: (prop) => {
if (
element &&
element.style &&
typeof element.style.getPropertyValue === 'function'
) {
return element.style.getPropertyValue(prop) || '';
}
return '';
}
};
};
// 清理函数
afterEach(() => {
// 清空 localStorage
localStorage.clear();
// 清空 document.body
document.body.innerHTML = '';
// 重置 CSS 变量
if (document.documentElement.style) {
document.documentElement.style.cssText = '';
}
});
+297
View File
@@ -0,0 +1,297 @@
/**
* 测试辅助工具(布局系统)
*
* 说明:
* - 本文件用于 Jest(jsdom) 环境下快速搭建 DOM、模拟事件、读取 CSS 变量。
* - 测试用 DOM 结构尽量与 TDD 第八章的集成结构一致,便于后续集成/回归测试复用。
*/
/**
* 创建完整的布局 DOM 结构
*/
function createLayoutDOM() {
const html = `
<div class="main-container">
<!-- 分组面板 -->
<div class="resizable-panel" data-panel="groups">
<div class="panel-header">
<h3>分组</h3>
<button class="collapse-btn"
data-panel="groups"
aria-label="折叠分组面板"
aria-expanded="true">
←
</button>
</div>
<div class="panel-content">
<p>分组内容</p>
</div>
<div class="resizer"
role="separator"
aria-orientation="vertical"
aria-label="调整分组面板宽度"
aria-valuenow="200"
aria-valuemin="150"
aria-valuemax="400"
tabindex="0"
data-resizer="groups">
</div>
</div>
<!-- 账号面板 -->
<div class="resizable-panel" data-panel="accounts">
<div class="panel-header">
<h3>账号</h3>
<button class="collapse-btn"
data-panel="accounts"
aria-label="折叠账号面板"
aria-expanded="true">
←
</button>
</div>
<div class="panel-content">
<p>账号内容</p>
</div>
<div class="resizer"
role="separator"
aria-orientation="vertical"
aria-label="调整账号面板宽度"
aria-valuenow="260"
aria-valuemin="180"
aria-valuemax="500"
tabindex="0"
data-resizer="accounts">
</div>
</div>
<!-- 邮件列表面板 -->
<div class="resizable-panel" data-panel="emails">
<div class="panel-header">
<h3>邮件列表</h3>
<button class="collapse-btn"
data-panel="emails"
aria-label="折叠邮件列表面板"
aria-expanded="true">
←
</button>
</div>
<div class="panel-content">
<p>邮件列表内容</p>
</div>
<div class="resizer"
role="separator"
aria-orientation="vertical"
aria-label="调整邮件列表面板宽度"
aria-valuenow="380"
aria-valuemin="280"
aria-valuemax="600"
tabindex="0"
data-resizer="emails">
</div>
</div>
<!-- 邮件详情面板(不可折叠) -->
<div class="email-detail-panel">
<div class="panel-header">
<h3>邮件详情</h3>
</div>
<div class="panel-content">
<p>邮件详情内容</p>
</div>
</div>
</div>
`;
document.body.innerHTML = html;
// 设置初始 CSS 变量(与 static/css/layout.css 命名保持一致)
document.documentElement.style.setProperty('--group-panel-width', '200px');
document.documentElement.style.setProperty('--account-panel-width', '260px');
document.documentElement.style.setProperty('--email-list-panel-width', '380px');
}
/**
* 创建简化的布局 DOM 结构(用于最小化单元测试场景)
*/
function createSimpleLayoutDOM() {
const html = `
<div class="main-container">
<div class="resizable-panel" data-panel="groups">
<div class="resizer" data-resizer="groups"></div>
</div>
</div>
`;
document.body.innerHTML = html;
document.documentElement.style.setProperty('--group-panel-width', '200px');
}
/**
* 获取 CSS 变量值
* @param {string} varName 例如:--group-panel-width
* @returns {string}
*/
function getCSSVariable(varName) {
return document.documentElement.style.getPropertyValue(varName).trim();
}
/**
* 设置 CSS 变量值
* @param {string} varName
* @param {string} value
*/
function setCSSVariable(varName, value) {
document.documentElement.style.setProperty(varName, value);
}
/**
* 模拟鼠标拖动事件
* @param {HTMLElement} element 触发 mousedown 的元素
* @param {number} startX 起始 clientX
* @param {number} endX 结束 clientX
*/
function simulateDrag(element, startX, endX) {
const mousedownEvent = new MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
clientX: startX
});
const mousemoveEvent = new MouseEvent('mousemove', {
bubbles: true,
cancelable: true,
clientX: endX
});
const mouseupEvent = new MouseEvent('mouseup', {
bubbles: true,
cancelable: true
});
element.dispatchEvent(mousedownEvent);
document.dispatchEvent(mousemoveEvent);
document.dispatchEvent(mouseupEvent);
}
/**
* 模拟键盘事件
* @param {HTMLElement} element
* @param {string} key
* @param {boolean} shiftKey
*/
function simulateKeyPress(element, key, shiftKey = false) {
const keydownEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key,
shiftKey
});
element.dispatchEvent(keydownEvent);
}
/**
* 等待指定时间
* @param {number} ms
* @returns {Promise<void>}
*/
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 等待过渡动画完成(监听 transitionend)
* @param {HTMLElement} element
* @param {number} timeout
* @returns {Promise<void>}
*/
function waitForTransition(element, timeout = 1000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Transition timeout'));
}, timeout);
element.addEventListener(
'transitionend',
() => {
clearTimeout(timer);
resolve();
},
{ once: true }
);
});
}
/**
* 创建测试用的 localStorage 数据(StateManager 相关测试会使用)
* @param {string} userId
* @param {Record<string, any>} overrides
*/
function createTestState(userId, overrides = {}) {
return {
version: '1.1',
userId,
timestamp: Date.now(),
panels: {
groups: { width: '200px', collapsed: false },
accounts: { width: '260px', collapsed: false },
emails: { width: '380px', collapsed: false },
...overrides
}
};
}
/**
* 模拟窗口大小变化
* @param {number} width
* @param {number} height
*/
function setWindowSize(width, height) {
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: width
});
Object.defineProperty(window, 'innerHeight', {
writable: true,
configurable: true,
value: height
});
window.dispatchEvent(new Event('resize'));
}
/**
* 检查元素是否可见(基于 offset)
* @param {HTMLElement} element
* @returns {boolean}
*/
function isVisible(element) {
return element.offsetWidth > 0 && element.offsetHeight > 0;
}
/**
* 检查元素是否有焦点
* @param {HTMLElement} element
* @returns {boolean}
*/
function hasFocus(element) {
return document.activeElement === element;
}
module.exports = {
createLayoutDOM,
createSimpleLayoutDOM,
getCSSVariable,
setCSSVariable,
simulateDrag,
simulateKeyPress,
wait,
waitForTransition,
createTestState,
setWindowSize,
isVisible,
hasFocus
};
@@ -0,0 +1,623 @@
/**
* LayoutManager 单元测试(TASK-02-001)
*
* 覆盖范围:
* - 类基础结构与 init()
* - getMinWidth() / getMaxWidth()
* - updatePanelWidth()
*/
const LayoutManager = require('../../../static/js/layout-manager.js');
const {
createLayoutDOM,
getCSSVariable,
simulateDrag,
wait
} = require('../test-utils.js');
describe('LayoutManager 单元测试(TASK-02-001)', () => {
let layoutManager;
beforeEach(() => {
createLayoutDOM();
// jsdom 默认 innerWidth 较小会触发“详情面板最小宽度”约束,影响基础用例;
// 这里统一设置为较宽的视口,避免非目标场景干扰。
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1600
});
layoutManager = new LayoutManager();
layoutManager.init();
});
test('init() 应该缓存 panels 与 resizers', () => {
expect(layoutManager.container).not.toBeNull();
expect(layoutManager.panels.get('groups')).toBeTruthy();
expect(layoutManager.panels.get('accounts')).toBeTruthy();
expect(layoutManager.panels.get('emails')).toBeTruthy();
expect(layoutManager.resizers.get('groups')).toBeTruthy();
expect(layoutManager.resizers.get('accounts')).toBeTruthy();
expect(layoutManager.resizers.get('emails')).toBeTruthy();
});
describe('getMinWidth() - 获取最小宽度', () => {
test('应该返回分组面板的最小宽度', () => {
expect(layoutManager.getMinWidth('groups')).toBe(150);
});
test('应该返回账号面板的最小宽度', () => {
expect(layoutManager.getMinWidth('accounts')).toBe(180);
});
test('应该返回邮件列表面板的最小宽度', () => {
expect(layoutManager.getMinWidth('emails')).toBe(280);
});
test('未知类型应该回退到默认最小宽度', () => {
expect(layoutManager.getMinWidth('unknown')).toBe(150);
});
});
describe('getMaxWidth() - 获取最大宽度', () => {
test('应该返回分组面板的最大宽度', () => {
expect(layoutManager.getMaxWidth('groups')).toBe(400);
});
test('应该返回账号面板的最大宽度', () => {
expect(layoutManager.getMaxWidth('accounts')).toBe(500);
});
test('应该返回邮件列表面板的最大宽度', () => {
expect(layoutManager.getMaxWidth('emails')).toBe(600);
});
test('未知类型应该回退到默认最大宽度', () => {
expect(layoutManager.getMaxWidth('unknown')).toBe(600);
});
});
describe('updatePanelWidth() - 更新面板宽度', () => {
test('应该正确更新 CSS 变量并同步 aria-valuenow', () => {
const panel = document.querySelector('[data-panel="groups"]');
const resizer = document.querySelector('[data-resizer="groups"]');
layoutManager.updatePanelWidth(panel, 250);
expect(getCSSVariable('--group-panel-width')).toBe('250px');
expect(resizer.getAttribute('aria-valuenow')).toBe('250');
});
test('无效参数不应抛错', () => {
expect(() => layoutManager.updatePanelWidth(null, 200)).not.toThrow();
expect(() => layoutManager.updatePanelWidth({}, 200)).not.toThrow();
expect(() => {
const panel = document.querySelector('[data-panel="groups"]');
layoutManager.updatePanelWidth(panel, NaN);
}).not.toThrow();
});
test('未知 panelType 不应修改 CSS 变量', () => {
const before = getCSSVariable('--group-panel-width');
const panel = document.createElement('div');
panel.dataset.panel = 'unknown';
layoutManager.updatePanelWidth(panel, 333);
expect(getCSSVariable('--group-panel-width')).toBe(before);
});
});
describe('折叠/展开(TASK-03-001)', () => {
test('getExpandedPanels() 应返回当前展开的侧边栏面板(TASK-03-005)', () => {
expect(layoutManager.getExpandedPanels()).toEqual(['groups', 'accounts', 'emails']);
layoutManager.collapsePanel('accounts', true);
expect(layoutManager.getExpandedPanels()).toEqual(['groups', 'emails']);
layoutManager.collapsePanel('emails', true);
expect(layoutManager.getExpandedPanels()).toEqual(['groups']);
});
test('TC-F2-003-N: 不能折叠最后一个展开的面板(仅限制手动折叠)', () => {
// 先折叠其他两个面板(auto=true 不受限制)
layoutManager.collapsePanel('accounts', true);
layoutManager.collapsePanel('emails', true);
const groupsPanel = document.querySelector('[data-panel="groups"]');
expect(groupsPanel.classList.contains('collapsed')).toBe(false);
// stub toast
const originalToast = window.showToast;
window.showToast = jest.fn();
try {
layoutManager.collapsePanel('groups', false);
// 应保持展开并提示
expect(groupsPanel.classList.contains('collapsed')).toBe(false);
expect(window.showToast).toHaveBeenCalledWith('至少需要保持一个面板展开', 'warning');
// auto=true 允许折叠(为后续窗口自适应留口子)
layoutManager.collapsePanel('groups', true);
expect(groupsPanel.classList.contains('collapsed')).toBe(true);
} finally {
window.showToast = originalToast;
}
});
test('TC-F2-005-N: 折叠状态下不能拖动调整宽度(TASK-03-006)', async () => {
const panel = document.querySelector('[data-panel="groups"]');
const resizer = document.querySelector('[data-resizer="groups"]');
layoutManager.collapsePanel('groups', false);
expect(panel.classList.contains('collapsed')).toBe(true);
// 尝试触发拖动(应被 startResize 直接忽略)
resizer.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 0 })
);
document.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 260 })
);
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
expect(layoutManager.isResizing).toBe(false);
expect(document.body.classList.contains('layout-resizing')).toBe(false);
});
test('TASK-03-007: 自动折叠应设置 data-autoCollapsed 标记,展开/手动折叠应移除', () => {
const panel = document.querySelector('[data-panel="groups"]');
layoutManager.collapsePanel('groups', true);
expect(panel.dataset.autoCollapsed).toBe('true');
layoutManager.expandPanel('groups');
expect(panel.dataset.autoCollapsed).toBeUndefined();
layoutManager.collapsePanel('groups', false);
expect(panel.dataset.autoCollapsed).toBeUndefined();
});
test('折叠/展开应更新按钮图标与 aria-label(TASK-03-002)', () => {
const panel = document.querySelector('[data-panel="groups"]');
const btn = panel.querySelector('.collapse-btn');
expect(btn.textContent.trim()).toBe('←');
expect(btn.getAttribute('aria-label')).toBe('折叠分组面板');
expect(btn.getAttribute('aria-expanded')).toBe('true');
layoutManager.collapsePanel('groups');
expect(btn.textContent.trim()).toBe('→');
expect(btn.getAttribute('aria-label')).toBe('展开分组面板');
expect(btn.getAttribute('aria-expanded')).toBe('false');
layoutManager.expandPanel('groups');
expect(btn.textContent.trim()).toBe('←');
expect(btn.getAttribute('aria-label')).toBe('折叠分组面板');
expect(btn.getAttribute('aria-expanded')).toBe('true');
});
test('collapsePanel() 应添加 collapsed 类并更新 aria-expanded', () => {
const panel = document.querySelector('[data-panel="groups"]');
const btn = panel.querySelector('.collapse-btn');
expect(panel.classList.contains('collapsed')).toBe(false);
expect(btn.getAttribute('aria-expanded')).toBe('true');
layoutManager.collapsePanel('groups');
expect(panel.classList.contains('collapsed')).toBe(true);
expect(btn.getAttribute('aria-expanded')).toBe('false');
});
test('expandPanel() 应移除 collapsed 类并更新 aria-expanded', () => {
const panel = document.querySelector('[data-panel="groups"]');
const btn = panel.querySelector('.collapse-btn');
layoutManager.collapsePanel('groups');
expect(panel.classList.contains('collapsed')).toBe(true);
layoutManager.expandPanel('groups');
expect(panel.classList.contains('collapsed')).toBe(false);
expect(btn.getAttribute('aria-expanded')).toBe('true');
});
test('togglePanel() 应在折叠/展开之间切换', () => {
const panel = document.querySelector('[data-panel="groups"]');
const btn = panel.querySelector('.collapse-btn');
layoutManager.togglePanel('groups');
expect(panel.classList.contains('collapsed')).toBe(true);
expect(btn.getAttribute('aria-expanded')).toBe('false');
layoutManager.togglePanel('groups');
expect(panel.classList.contains('collapsed')).toBe(false);
expect(btn.getAttribute('aria-expanded')).toBe('true');
});
test('未知 panelType 应安全返回且不抛错', () => {
expect(() => layoutManager.collapsePanel('unknown')).not.toThrow();
expect(() => layoutManager.expandPanel('unknown')).not.toThrow();
expect(() => layoutManager.togglePanel('unknown')).not.toThrow();
});
});
describe('calculateNewWidth() - 计算新宽度', () => {
test('TC-F1-001-P: 应该正确计算拖动后的宽度', () => {
const newWidth = layoutManager.calculateNewWidth(200, 250, 200, 'groups');
expect(newWidth).toBe(250);
});
test('TC-F1-002-B: 应该限制在最小宽度', () => {
const newWidth = layoutManager.calculateNewWidth(200, 0, 200, 'groups');
expect(newWidth).toBe(150);
});
test('TC-F1-003-B: 应该限制在最大宽度', () => {
const newWidth = layoutManager.calculateNewWidth(200, 999, 200, 'groups');
expect(newWidth).toBe(400);
});
test('TC-F1-004-B: 应该保证邮件详情面板至少 400px', () => {
// 设置一个较窄窗口并调整其他面板宽度,让 groups 的最大允许宽度受到约束
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1300
});
document.documentElement.style.setProperty('--account-panel-width', '300px');
document.documentElement.style.setProperty('--email-list-panel-width', '280px');
const newWidth = layoutManager.calculateNewWidth(0, 1000, 200, 'groups');
// 1300 - (300+280) - 400 = 320
expect(newWidth).toBe(320);
});
});
describe('calculateOtherPanelsWidth() - 计算其他面板总宽度', () => {
test('应返回除自身外的侧边栏宽度之和', () => {
// 默认:200 / 260 / 380
expect(layoutManager.calculateOtherPanelsWidth('groups')).toBe(260 + 380);
expect(layoutManager.calculateOtherPanelsWidth('accounts')).toBe(200 + 380);
expect(layoutManager.calculateOtherPanelsWidth('emails')).toBe(200 + 260);
});
});
describe('折叠/展开(TASK-03-001)', () => {
test('collapsePanel() 应添加 collapsed 类并更新 aria-expanded=false', () => {
const panel = document.querySelector('[data-panel="groups"]');
const btn = panel.querySelector('.collapse-btn');
expect(panel.classList.contains('collapsed')).toBe(false);
expect(btn.getAttribute('aria-expanded')).toBe('true');
layoutManager.collapsePanel('groups');
expect(panel.classList.contains('collapsed')).toBe(true);
expect(btn.getAttribute('aria-expanded')).toBe('false');
});
test('expandPanel() 应移除 collapsed 类并更新 aria-expanded=true', () => {
const panel = document.querySelector('[data-panel="groups"]');
const btn = panel.querySelector('.collapse-btn');
layoutManager.collapsePanel('groups');
expect(panel.classList.contains('collapsed')).toBe(true);
layoutManager.expandPanel('groups');
expect(panel.classList.contains('collapsed')).toBe(false);
expect(btn.getAttribute('aria-expanded')).toBe('true');
});
test('togglePanel() 应在折叠/展开之间切换', () => {
const panel = document.querySelector('[data-panel="groups"]');
layoutManager.togglePanel('groups');
expect(panel.classList.contains('collapsed')).toBe(true);
layoutManager.togglePanel('groups');
expect(panel.classList.contains('collapsed')).toBe(false);
});
test('未知 panelType 不应抛错', () => {
expect(() => layoutManager.collapsePanel('unknown')).not.toThrow();
expect(() => layoutManager.expandPanel('unknown')).not.toThrow();
expect(() => layoutManager.togglePanel('unknown')).not.toThrow();
});
});
describe('parsePx() - 解析 px 字符串', () => {
test('应该解析有效 px', () => {
expect(layoutManager.parsePx('200px')).toBe(200);
expect(layoutManager.parsePx(' 260px ')).toBe(260);
});
test('无效格式返回 null', () => {
expect(layoutManager.parsePx('200')).toBeNull();
expect(layoutManager.parsePx('abcpx')).toBeNull();
expect(layoutManager.parsePx('')).toBeNull();
expect(layoutManager.parsePx(null)).toBeNull();
});
});
test('TC-F1-001-P: startResize/resize/stopResize 基本拖动流程应更新宽度', async () => {
const resizer = document.querySelector('[data-resizer="groups"]');
// 初始宽度
expect(getCSSVariable('--group-panel-width')).toBe('200px');
// 模拟拖动:从 200px 拖到 250px
simulateDrag(resizer, 200, 250);
await wait(50);
// 宽度已更新
expect(getCSSVariable('--group-panel-width')).toBe('250px');
});
describe('分支覆盖补充(保证覆盖率阈值)', () => {
test('getPanelDisplayName() 应覆盖 accounts/emails/default 分支', () => {
expect(layoutManager.getPanelDisplayName('accounts')).toBe('账号');
expect(layoutManager.getPanelDisplayName('emails')).toBe('邮件列表');
expect(layoutManager.getPanelDisplayName('unknown')).toBe('面板');
});
test('onCollapseButtonClick() 应根据 data-panel 调用 togglePanel', () => {
const btn = document.querySelector('.collapse-btn[data-panel="groups"]');
const spy = jest.spyOn(layoutManager, 'togglePanel');
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(spy).toHaveBeenCalledWith('groups');
});
test('onCollapseButtonClick() data-panel 缺失时应安全返回', () => {
expect(() =>
layoutManager.onCollapseButtonClick({
currentTarget: { dataset: {} },
preventDefault: () => {}
})
).not.toThrow();
expect(() => layoutManager.onCollapseButtonClick(null)).not.toThrow();
});
test('ensureCollapsedIndicators() 应创建指示器并避免重复创建', () => {
const indicators = document.querySelectorAll('.collapsed-indicator');
expect(indicators.length).toBe(3);
const groupsIndicator = layoutManager.indicators.get('groups');
expect(groupsIndicator).toBeTruthy();
expect(groupsIndicator.getAttribute('role')).toBe('button');
expect(groupsIndicator.getAttribute('tabindex')).toBe('0');
expect(groupsIndicator.getAttribute('aria-label')).toBe('展开分组面板');
expect(groupsIndicator.querySelector('.indicator-text').textContent).toBe('分组');
const before = document.querySelectorAll('.collapsed-indicator').length;
layoutManager.ensureCollapsedIndicators();
const after = document.querySelectorAll('.collapsed-indicator').length;
expect(after).toBe(before);
});
test('点击/键盘操作折叠指示器应展开面板(TASK-03-004)', () => {
const panel = document.querySelector('[data-panel="groups"]');
const indicator = layoutManager.indicators.get('groups');
layoutManager.collapsePanel('groups', true);
expect(panel.classList.contains('collapsed')).toBe(true);
indicator.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(panel.classList.contains('collapsed')).toBe(false);
layoutManager.collapsePanel('groups', true);
expect(panel.classList.contains('collapsed')).toBe(true);
indicator.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter' })
);
expect(panel.classList.contains('collapsed')).toBe(false);
});
test('onIndicatorClick()/onIndicatorKeydown() 无效参数应安全返回', () => {
expect(() => layoutManager.onIndicatorClick(null)).not.toThrow();
expect(() =>
layoutManager.onIndicatorClick({
currentTarget: { dataset: {} },
preventDefault: () => {}
})
).not.toThrow();
expect(() => layoutManager.onIndicatorKeydown(null)).not.toThrow();
expect(() =>
layoutManager.onIndicatorKeydown({
key: 'ArrowRight',
preventDefault: () => {},
currentTarget: {}
})
).not.toThrow();
});
test('updateCollapsedIndicatorOffsets() 多面板折叠时应设置不同偏移', () => {
const groupsIndicator = layoutManager.indicators.get('groups');
const accountsIndicator = layoutManager.indicators.get('accounts');
layoutManager.collapsePanel('groups', true);
layoutManager.collapsePanel('accounts', true);
expect(groupsIndicator.style.getPropertyValue('--collapsed-indicator-offset')).toBe(
'0px'
);
expect(accountsIndicator.style.getPropertyValue('--collapsed-indicator-offset')).toBe(
'48px'
);
// 覆盖 parsed 分支:自定义指示器宽度
document.documentElement.style.setProperty('--panel-collapsed-indicator-width', '60px');
layoutManager.updateCollapsedIndicatorOffsets();
expect(accountsIndicator.style.getPropertyValue('--collapsed-indicator-offset')).toBe(
'60px'
);
});
test('syncAllPanelsLayoutVars() 应覆盖折叠分支', () => {
layoutManager.collapsePanel('groups', true);
layoutManager.syncAllPanelsLayoutVars();
expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe(
'0px'
);
});
test('getPanelLayoutWidth() 面板折叠时应返回 0', () => {
const panel = document.querySelector('[data-panel="groups"]');
layoutManager.collapsePanel('groups', true);
expect(layoutManager.getPanelLayoutWidth('groups', panel)).toBe(0);
});
test('updatePanelWidth() 在折叠状态下不应覆盖布局宽度变量', () => {
const panel = document.querySelector('[data-panel="groups"]');
layoutManager.collapsePanel('groups', true);
expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe(
'0px'
);
layoutManager.updatePanelWidth(panel, 250);
expect(document.documentElement.style.getPropertyValue('--group-panel-width')).toBe('250px');
expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe(
'0px'
);
});
test('getContainerWidth() 应覆盖 rect 与 0 的分支', () => {
layoutManager.container.getBoundingClientRect = () => ({ width: 999 });
expect(layoutManager.getContainerWidth()).toBe(999);
// 无容器 + innerWidth 非数字 => 0
layoutManager.container = null;
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 'abc'
});
expect(layoutManager.getContainerWidth()).toBe(0);
});
test('startResize() 应忽略非左键点击', async () => {
const resizer = document.querySelector('[data-resizer="groups"]');
resizer.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 1 })
);
document.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 250 })
);
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
await wait(30);
expect(getCSSVariable('--group-panel-width')).toBe('200px');
});
test('resize() 在未开始拖动时应直接返回', () => {
expect(() => layoutManager.resize(new MouseEvent('mousemove'))).not.toThrow();
expect(layoutManager.rafId).toBe(0);
});
test('resize() 同一帧内应只调度一次 requestAnimationFrame', async () => {
const resizer = document.querySelector('[data-resizer="groups"]');
const rafSpy = jest.spyOn(global, 'requestAnimationFrame');
resizer.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 0 })
);
document.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 240 })
);
document.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 250 })
);
expect(rafSpy).toHaveBeenCalledTimes(1);
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
await wait(30);
rafSpy.mockRestore();
});
test('stopResize() 在未开始拖动时应直接返回', () => {
expect(() => layoutManager.stopResize()).not.toThrow();
});
test('stopResize() 在无 mousemove 时不应进入 raf flush 分支', async () => {
const resizer = document.querySelector('[data-resizer="groups"]');
resizer.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 0 })
);
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
await wait(30);
expect(getCSSVariable('--group-panel-width')).toBe('200px');
});
test('getPanelWidth() 在 CSS 变量无法解析时应回退到 getBoundingClientRect', () => {
const panel = document.createElement('div');
panel.getBoundingClientRect = () => ({ width: 123 });
expect(layoutManager.getPanelWidth('unknown', panel)).toBe(123);
});
test('getPanelWidth() 在 rect 无效时应回退到 offsetWidth', () => {
const panel = document.createElement('div');
panel.getBoundingClientRect = () => ({ width: 0 });
Object.defineProperty(panel, 'offsetWidth', { value: 88 });
expect(layoutManager.getPanelWidth('unknown', panel)).toBe(88);
});
test('getPanelWidth() 在 CSS 变量不可解析且无元素宽度时返回 0', () => {
document.documentElement.style.setProperty('--group-panel-width', 'auto');
expect(layoutManager.getPanelWidth('groups', null)).toBe(0);
});
test('startResize() panelType 无效/面板不存在时应安全返回', () => {
expect(() =>
layoutManager.startResize({
button: 0,
currentTarget: { dataset: { resizer: 'unknown' } },
clientX: 0,
preventDefault: () => {}
})
).not.toThrow();
expect(() =>
layoutManager.startResize({
button: 0,
currentTarget: {},
clientX: 0,
preventDefault: () => {}
})
).not.toThrow();
});
test('拖动开始/结束应切换全局与 resizer 视觉反馈状态', async () => {
const resizer = document.querySelector('[data-resizer="groups"]');
expect(document.body.classList.contains('layout-resizing')).toBe(false);
expect(resizer.classList.contains('active')).toBe(false);
resizer.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 0 })
);
expect(document.body.classList.contains('layout-resizing')).toBe(true);
expect(resizer.classList.contains('active')).toBe(true);
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
await wait(30);
expect(document.body.classList.contains('layout-resizing')).toBe(false);
expect(resizer.classList.contains('active')).toBe(false);
});
});
});
@@ -0,0 +1,266 @@
/**
* StateManager 单元测试(TASK-04-001)
*/
const StateManager = require('../../../static/js/state-manager.js');
const { createTestState } = require('../test-utils.js');
describe('StateManager 单元测试(TASK-04-001)', () => {
let stateManager;
beforeEach(() => {
stateManager = new StateManager();
});
test('getStorageKey() 应支持 userId 与 guest', () => {
expect(stateManager.getStorageKey('user123')).toBe('outlook_layout_state_user123');
expect(stateManager.getStorageKey(' userA ')).toBe('outlook_layout_state_userA');
expect(stateManager.getStorageKey('')).toBe('outlook_layout_state_guest');
expect(stateManager.getStorageKey(null)).toBe('outlook_layout_state_guest');
});
test('save()/load() 应能完成状态保存与读取', () => {
const userId = 'user123';
const state = createTestState(userId);
const ok = stateManager.save(userId, state);
expect(ok).toBe(true);
const loaded = stateManager.load(userId);
expect(loaded).toBeTruthy();
expect(loaded.version).toBe('1.1');
expect(loaded.userId).toBe(userId);
expect(loaded.panels.groups.width).toBe('200px');
expect(loaded.panels.groups.collapsed).toBe(false);
});
test('clear() 应清除指定用户的状态', () => {
const userId = 'user123';
const state = createTestState(userId);
stateManager.save(userId, state);
expect(stateManager.load(userId)).toBeTruthy();
stateManager.clear(userId);
expect(stateManager.load(userId)).toBeNull();
});
test('validate() 应验证数据格式', () => {
const good = createTestState('u1');
expect(stateManager.validate(good)).toBe(true);
const badWidth = createTestState('u1');
badWidth.panels.groups.width = '200';
expect(stateManager.validate(badWidth)).toBe(false);
const badCollapsed = createTestState('u1');
badCollapsed.panels.groups.collapsed = 'yes';
expect(stateManager.validate(badCollapsed)).toBe(false);
expect(stateManager.validate(null)).toBe(false);
expect(stateManager.validate({ version: '1.1' })).toBe(false);
});
test('migrate() 应补齐/修正 version', () => {
const migrated1 = stateManager.migrate({ panels: createTestState('u1').panels });
expect(migrated1.version).toBe('1.1');
const migrated2 = stateManager.migrate({ version: '1.0', panels: createTestState('u1').panels });
expect(migrated2.version).toBe('1.1');
});
test('load() JSON 损坏时应清除并返回 null', () => {
const userId = 'u1';
const key = stateManager.getStorageKey(userId);
window.localStorage.setItem(key, '{bad json');
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const loaded = stateManager.load(userId);
expect(loaded).toBeNull();
expect(window.localStorage.getItem(key)).toBeNull();
errSpy.mockRestore();
});
test('save() localStorage 不可用时应返回 false', () => {
const userId = 'u1';
const state = createTestState(userId);
const originalLocalStorage = window.localStorage;
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: null
});
try {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
expect(stateManager.save(userId, state)).toBe(false);
warnSpy.mockRestore();
} finally {
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: originalLocalStorage
});
}
});
test('getStorage() 在访问 localStorage 抛错时应返回 null', () => {
const originalLocalStorage = window.localStorage;
Object.defineProperty(window, 'localStorage', {
configurable: true,
get() {
throw new Error('blocked');
}
});
expect(stateManager.getStorage()).toBeNull();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: originalLocalStorage
});
});
test('isQuotaExceededError() 应识别不同浏览器的 quota 错误形态', () => {
expect(stateManager.isQuotaExceededError(null)).toBe(false);
expect(stateManager.isQuotaExceededError({ name: 'QuotaExceededError' })).toBe(true);
expect(stateManager.isQuotaExceededError({ name: 'NS_ERROR_DOM_QUOTA_REACHED' })).toBe(true);
expect(stateManager.isQuotaExceededError({ code: 22 })).toBe(true);
expect(stateManager.isQuotaExceededError({ code: 1014 })).toBe(true);
expect(stateManager.isQuotaExceededError({ name: 'OtherError', code: 123 })).toBe(false);
});
test('clearOldStates() 应清理旧状态并保留 excludeKey', () => {
const k1 = 'outlook_layout_state_u1';
const k2 = 'outlook_layout_state_u2';
const excludeKey = 'outlook_layout_state_keep';
const otherKey = 'not_outlook_key';
window.localStorage.setItem(k1, JSON.stringify({ timestamp: 1 }));
window.localStorage.setItem(k2, '{bad json'); // 解析失败应仍被清理
window.localStorage.setItem(excludeKey, JSON.stringify({ timestamp: 999 }));
window.localStorage.setItem(otherKey, 'x');
stateManager.clearOldStates(excludeKey);
expect(window.localStorage.getItem(k1)).toBeNull();
expect(window.localStorage.getItem(k2)).toBeNull();
expect(window.localStorage.getItem(excludeKey)).toBeTruthy();
expect(window.localStorage.getItem(otherKey)).toBe('x');
});
test('save() 遇到 QuotaExceededError 时应清理旧数据并重试', () => {
const userId = 'u1';
const state = createTestState(userId);
const key = stateManager.getStorageKey(userId);
// 使用可控的 storage stub:第一次 setItem 抛 QuotaExceededError,第二次成功
const store = {};
let throwOnce = true;
const storageStub = {
setItem: jest.fn((k, v) => {
if (throwOnce) {
throwOnce = false;
const err = new Error('quota');
err.name = 'QuotaExceededError';
throw err;
}
store[k] = String(v);
}),
getItem: jest.fn((k) => store[k] || null),
removeItem: jest.fn((k) => {
delete store[k];
})
};
const getStorageSpy = jest.spyOn(stateManager, 'getStorage').mockReturnValue(storageStub);
const clearSpy = jest.spyOn(stateManager, 'clearOldStates').mockImplementation(() => {});
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ok = stateManager.save(userId, state);
expect(ok).toBe(true);
expect(storageStub.setItem).toHaveBeenCalledTimes(2);
expect(clearSpy).toHaveBeenCalledWith(key);
expect(storageStub.getItem(key)).toBeTruthy();
} finally {
warnSpy.mockRestore();
clearSpy.mockRestore();
getStorageSpy.mockRestore();
}
});
test('save() 数据格式无效时应返回 false(不写入 storage)', () => {
const userId = 'u1';
const badState = createTestState(userId);
badState.panels.groups.width = '200'; // 非 px
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ok = stateManager.save(userId, badState);
expect(ok).toBe(false);
expect(window.localStorage.getItem(stateManager.getStorageKey(userId))).toBeNull();
} finally {
warnSpy.mockRestore();
}
});
test('load() 数据校验失败时应清除并返回 null', () => {
const userId = 'u1';
const key = stateManager.getStorageKey(userId);
window.localStorage.setItem(
key,
JSON.stringify({
version: '1.1',
userId,
timestamp: Date.now(),
panels: {
groups: { width: '200', collapsed: false }, // invalid
accounts: { width: '260px', collapsed: false },
emails: { width: '380px', collapsed: false }
}
})
);
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
const loaded = stateManager.load(userId);
expect(loaded).toBeNull();
expect(window.localStorage.getItem(key)).toBeNull();
} finally {
warnSpy.mockRestore();
}
});
test('load() storage.getItem 抛错时应返回 null(不抛异常)', () => {
const userId = 'u1';
const originalLocalStorage = window.localStorage;
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
getItem: () => {
throw new Error('denied');
},
setItem: () => {},
removeItem: () => {},
key: () => null,
get length() {
return 0;
}
}
});
try {
expect(stateManager.load(userId)).toBeNull();
} finally {
warnSpy.mockRestore();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: originalLocalStorage
});
}
});
});