From ba83ca3bf68602703fd459b146c8da50f4830240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E7=BB=8D=E5=BA=B7?= <3052147989@qq.com> Date: Sun, 1 Mar 2026 21:11:55 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=87=86=E5=A4=87=E5=BC=80=E6=BA=90?= =?UTF-8?q?=E5=8F=91=E5=B8=83=20-=20=E5=8F=AA=E4=BF=9D=E7=95=99=E6=BA=90?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除所有非源代码文件: - 删除 docs/ 目录(所有内部文档) - 删除 tests/ 目录(所有测试文件) - 删除 .github/ 目录(CI/CD 配置) - 删除 .kiro/ 目录(IDE 配置) - 删除 CONTRIBUTING.md, SECURITY.md 更新项目文件: - 重写 README.md,简洁清晰(从 1600+ 行压缩到 237 行) - 更新 .gitignore,忽略所有非源代码文件 - 使用 ASCII art 展示界面布局 - 添加 Shields.io 徽章美化项目 保留的文件: - 源代码:outlook_web/, static/, templates/ - 入口文件:web_outlook_app.py, start.py, outlook_mail_reader.py - 配置文件:requirements.txt, Dockerfile, .dockerignore, .env.example - 项目文档:README.md, LICENSE, .gitignore Co-Authored-By: Claude Opus 4.6 --- .github/ISSUE_TEMPLATE/bug_report.md | 37 - .github/ISSUE_TEMPLATE/feature_request.md | 32 - .github/PULL_REQUEST_TEMPLATE.md | 33 - .github/dependabot.yml | 40 - .github/labels.yml | 84 - .github/workflows/code-quality.yml | 75 - .github/workflows/docker-build-push.yml | 62 - .github/workflows/python-tests.yml | 48 - .gitignore | 55 +- .kiro/steering/architecture.md | 366 -- .kiro/steering/project-overview.md | 223 - .kiro/steering/tech-stack.md | 345 -- CONTRIBUTING.md | 149 - README.md | 1607 +----- SECURITY.md | 136 - docs/BUG/BUG-批量操作与验证码提取.md | 124 - docs/BUGS/BUG-00001-临时邮箱生成失败.md | 287 -- ...tlook邮件管理工具-重构升级-文档审查报告.md | 161 - ...¶管理工具-前后端拆分与模块化-文档审查报告.md | 154 - docs/DEV/00002-前后端拆分-开发者指南.md | 115 - docs/DEVLOG.md | 39 - docs/FD/FD-可调整布局系统.md | 1477 ------ ...‚®件管理工具-Legacy迁移到Controllers层FD.md | 602 --- ...utlook邮件管理工具-前后端拆分与模块化FD.md | 106 - ...look邮件管理工具-批量操作与自动化增强FD.md | 526 -- docs/FD/Outlook邮件管理工具-重构升级FD.md | 191 - docs/LOG/CN-00001-完成legacy删除.md | 77 - ...1-Outlook邮件管理工具-重构升级-执行日志.md | 127 - ...‚®件管理工具-前后端拆分与模块化-执行日志.md | 195 - docs/LOG/LOG-00003-可调整布局系统-执行日志.md | 120 - docs/PERF/性能与容量基线.md | 60 - ...tlook邮件管理工具-前后端拆分与模块化PRD.md | 282 -- ...ook邮件管理工具-批量操作与自动化增强PRD.md | 320 -- docs/PRD/Outlook邮件管理工具-重构升级PRD.md | 226 - ...PRD-00003-Legacy代码拆分到Controllers层.md | 245 - docs/QA/P0验收对照表.md | 177 - docs/QA/前后端拆分-接口契约抽样验收口径.md | 75 - docs/QA/回归验证清单.md | 171 - docs/RUN/00002-前后端拆分-回滚点与切换方式.md | 82 - docs/RUN/运行与定时刷新验证指南.md | 70 - docs/TD/TD-可调整布局系统.md | 633 --- ...®件管理工具-Legacy迁移到Controllers层TDD.md | 1033 ---- ...tlook邮件管理工具-前后端拆分与模块化TDD.md | 331 -- ...ook邮件管理工具-批量操作与自动化增强TDD.md | 873 ---- docs/TDD/Outlook邮件管理工具-重构升级TDD.md | 143 - docs/TDD/TDD-可调整布局系统.md | 1163 ----- ...¡理工具-Legacy迁移到Controllers层-测试文档.md | 531 -- ...¡理工具-批量操作与自动化增强-测试文档-补充.md | 732 --- ...件管理工具-批量操作与自动化增强-测试文档.md | 508 -- ...件管理工具-Legacy迁移到Controllers层TODO.md | 637 --- ...look邮件管理工具-前后端拆分与模块化TODO.md | 77 - ...ok邮件管理工具-批量操作与自动化增强TODO.md | 463 -- docs/TODO/Outlook邮件管理工具-重构升级TODO.md | 79 - docs/TODO/TODO-可调整布局系统.md | 1393 ------ static/js/features/accounts.js | 2 +- static/js/features/emails.js | 2 +- static/js/features/groups.js | 21 + static/js/features/temp_emails.js | 8 + static/js/main.js | 12 +- templates/index.html | 2 +- tests/__init__.py | 1 - tests/__pycache__/__init__.cpython-313.pyc | Bin 151 -> 0 bytes tests/__pycache__/_import_app.cpython-313.pyc | Bin 2097 -> 0 bytes ..._verification.cpython-313-pytest-8.4.2.pyc | Bin 5288 -> 0 bytes ...t_api_extract_verification.cpython-313.pyc | Bin 5159 -> 0 bytes ...core_features.cpython-313-pytest-8.4.2.pyc | Bin 16104 -> 0 bytes ...core_features.cpython-313-pytest-9.0.2.pyc | Bin 16078 -> 0 bytes .../test_core_features.cpython-313.pyc | Bin 16442 -> 0 bytes ...tributed_lock.cpython-313-pytest-8.4.2.pyc | Bin 3959 -> 0 bytes ...tributed_lock.cpython-313-pytest-9.0.2.pyc | Bin 3943 -> 0 bytes .../test_distributed_lock.cpython-313.pyc | Bin 3860 -> 0 bytes ...ror_and_trace.cpython-313-pytest-8.4.2.pyc | Bin 28797 -> 0 bytes ...ror_and_trace.cpython-313-pytest-9.0.2.pyc | Bin 26444 -> 0 bytes .../test_error_and_trace.cpython-313.pyc | Bin 29367 -> 0 bytes ...ontend_manual.cpython-313-pytest-8.4.2.pyc | Bin 15403 -> 0 bytes .../test_frontend_manual.cpython-313.pyc | Bin 15263 -> 0 bytes ...rror_handling.cpython-313-pytest-8.4.2.pyc | Bin 9853 -> 0 bytes ...est_gptmail_error_handling.cpython-313.pyc | Bin 9782 -> 0 bytes ...it_and_import.cpython-313-pytest-8.4.2.pyc | Bin 15398 -> 0 bytes ...it_and_import.cpython-313-pytest-9.0.2.pyc | Bin 15370 -> 0 bytes ...t_masking_audit_and_import.cpython-313.pyc | Bin 15641 -> 0 bytes ...le_boundaries.cpython-313-pytest-8.4.2.pyc | Bin 4812 -> 0 bytes .../test_module_boundaries.cpython-313.pyc | Bin 4681 -> 0 bytes ...t_performance.cpython-313-pytest-8.4.2.pyc | Bin 4731 -> 0 bytes .../test_performance.cpython-313.pyc | Bin 4602 -> 0 bytes ...moke_contract.cpython-313-pytest-8.4.2.pyc | Bin 8586 -> 0 bytes ...moke_contract.cpython-313-pytest-9.0.2.pyc | Bin 7207 -> 0 bytes .../test_smoke_contract.cpython-313.pyc | Bin 8797 -> 0 bytes .../test_ui_redesign_bugs.cpython-313.pyc | Bin 14320 -> 0 bytes ...ion_extractor.cpython-313-pytest-8.4.2.pyc | Bin 18973 -> 0 bytes ...est_verification_extractor.cpython-313.pyc | Bin 18836 -> 0 bytes tests/_import_app.py | 46 - tests/layout-system/README.md | 289 -- .../layout-accessibility.test.js | 353 -- tests/layout-system/coverage/clover.xml | 656 --- .../coverage/coverage-final.json | 3 - .../coverage/lcov-report/base.css | 224 - .../coverage/lcov-report/block-navigation.js | 87 - .../coverage/lcov-report/favicon.png | Bin 445 -> 0 bytes .../coverage/lcov-report/index.html | 131 - .../lcov-report/layout-manager.js.html | 4306 ----------------- .../coverage/lcov-report/prettify.css | 1 - .../coverage/lcov-report/prettify.js | 2 - .../lcov-report/sort-arrow-sprite.png | Bin 138 -> 0 bytes .../coverage/lcov-report/sorter.js | 210 - .../lcov-report/state-manager.js.html | 1045 ---- tests/layout-system/coverage/lcov.info | 1377 ------ .../integration/drag-resize.test.js | 112 - .../integration/keyboard-resizer.test.js | 75 - .../integration/panel-collapse.test.js | 115 - .../integration/reset-layout.test.js | 74 - .../integration/state-error-handling.test.js | 101 - .../integration/state-load.test.js | 60 - .../integration/state-multi-user.test.js | 82 - .../integration/state-save.test.js | 85 - .../integration/window-adapt.test.js | 125 - tests/layout-system/jest.config.js | 48 - .../performance/layout-performance.test.js | 270 -- tests/layout-system/setup.js | 113 - tests/layout-system/test-utils.js | 297 -- .../layout-system/unit/layout-manager.test.js | 623 --- .../layout-system/unit/state-manager.test.js | 266 - tests/test_api_extract_verification.py | 237 - tests/test_core_features.py | 253 - tests/test_distributed_lock.py | 64 - tests/test_error_and_trace.py | 475 -- tests/test_frontend_manual.py | 541 --- tests/test_gptmail_error_handling.py | 178 - tests/test_masking_audit_and_import.py | 272 -- tests/test_module_boundaries.py | 77 - tests/test_performance.py | 284 -- tests/test_smoke_contract.py | 131 - tests/test_ui_redesign_bugs.py | 230 - tests/test_verification_extractor.py | 420 -- 134 files changed, 200 insertions(+), 32083 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md delete mode 100644 .github/dependabot.yml delete mode 100644 .github/labels.yml delete mode 100644 .github/workflows/code-quality.yml delete mode 100644 .github/workflows/docker-build-push.yml delete mode 100644 .github/workflows/python-tests.yml delete mode 100644 .kiro/steering/architecture.md delete mode 100644 .kiro/steering/project-overview.md delete mode 100644 .kiro/steering/tech-stack.md delete mode 100644 CONTRIBUTING.md delete mode 100644 SECURITY.md delete mode 100644 docs/BUG/BUG-批量操作与验证码提取.md delete mode 100644 docs/BUGS/BUG-00001-临时邮箱生成失败.md delete mode 100644 docs/CR/CR-00001-Outlook邮件管理工具-重构升级-文档审查报告.md delete mode 100644 docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md delete mode 100644 docs/DEV/00002-前后端拆分-开发者指南.md delete mode 100644 docs/DEVLOG.md delete mode 100644 docs/FD/FD-可调整布局系统.md delete mode 100644 docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md delete mode 100644 docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md delete mode 100644 docs/FD/Outlook邮件管理工具-批量操作与自动化增强FD.md delete mode 100644 docs/FD/Outlook邮件管理工具-重构升级FD.md delete mode 100644 docs/LOG/CN-00001-完成legacy删除.md delete mode 100644 docs/LOG/LOG-00001-Outlook邮件管理工具-重构升级-执行日志.md delete mode 100644 docs/LOG/LOG-00002-Outlook邮件管理工具-前后端拆分与模块化-执行日志.md delete mode 100644 docs/LOG/LOG-00003-可调整布局系统-执行日志.md delete mode 100644 docs/PERF/性能与容量基线.md delete mode 100644 docs/PRD/Outlook邮件管理工具-前后端拆分与模块化PRD.md delete mode 100644 docs/PRD/Outlook邮件管理工具-批量操作与自动化增强PRD.md delete mode 100644 docs/PRD/Outlook邮件管理工具-重构升级PRD.md delete mode 100644 docs/PRD/PRD-00003-Legacy代码拆分到Controllers层.md delete mode 100644 docs/QA/P0验收对照表.md delete mode 100644 docs/QA/前后端拆分-接口契约抽样验收口径.md delete mode 100644 docs/QA/回归验证清单.md delete mode 100644 docs/RUN/00002-前后端拆分-回滚点与切换方式.md delete mode 100644 docs/RUN/运行与定时刷新验证指南.md delete mode 100644 docs/TD/TD-可调整布局系统.md delete mode 100644 docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md delete mode 100644 docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md delete mode 100644 docs/TDD/Outlook邮件管理工具-批量操作与自动化增强TDD.md delete mode 100644 docs/TDD/Outlook邮件管理工具-重构升级TDD.md delete mode 100644 docs/TDD/TDD-可调整布局系统.md delete mode 100644 docs/TEST/Outlook邮件管理工具-Legacy迁移到Controllers层-测试文档.md delete mode 100644 docs/TEST/Outlook邮件管理工具-批量操作与自动化增强-测试文档-补充.md delete mode 100644 docs/TEST/Outlook邮件管理工具-批量操作与自动化增强-测试文档.md delete mode 100644 docs/TODO/Outlook邮件管理工具-Legacy迁移到Controllers层TODO.md delete mode 100644 docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md delete mode 100644 docs/TODO/Outlook邮件管理工具-批量操作与自动化增强TODO.md delete mode 100644 docs/TODO/Outlook邮件管理工具-重构升级TODO.md delete mode 100644 docs/TODO/TODO-可调整布局系统.md delete mode 100644 tests/__init__.py delete mode 100644 tests/__pycache__/__init__.cpython-313.pyc delete mode 100644 tests/__pycache__/_import_app.cpython-313.pyc delete mode 100644 tests/__pycache__/test_api_extract_verification.cpython-313-pytest-8.4.2.pyc delete mode 100644 tests/__pycache__/test_api_extract_verification.cpython-313.pyc delete mode 100644 tests/__pycache__/test_core_features.cpython-313-pytest-8.4.2.pyc delete mode 100644 tests/__pycache__/test_core_features.cpython-313-pytest-9.0.2.pyc delete mode 100644 tests/__pycache__/test_core_features.cpython-313.pyc delete mode 100644 tests/__pycache__/test_distributed_lock.cpython-313-pytest-8.4.2.pyc delete mode 100644 tests/__pycache__/test_distributed_lock.cpython-313-pytest-9.0.2.pyc delete mode 100644 tests/__pycache__/test_distributed_lock.cpython-313.pyc delete mode 100644 tests/__pycache__/test_error_and_trace.cpython-313-pytest-8.4.2.pyc delete mode 100644 tests/__pycache__/test_error_and_trace.cpython-313-pytest-9.0.2.pyc delete mode 100644 tests/__pycache__/test_error_and_trace.cpython-313.pyc delete mode 100644 tests/__pycache__/test_frontend_manual.cpython-313-pytest-8.4.2.pyc delete mode 100644 tests/__pycache__/test_frontend_manual.cpython-313.pyc delete mode 100644 tests/__pycache__/test_gptmail_error_handling.cpython-313-pytest-8.4.2.pyc delete mode 100644 tests/__pycache__/test_gptmail_error_handling.cpython-313.pyc delete mode 100644 tests/__pycache__/test_masking_audit_and_import.cpython-313-pytest-8.4.2.pyc delete mode 100644 tests/__pycache__/test_masking_audit_and_import.cpython-313-pytest-9.0.2.pyc delete mode 100644 tests/__pycache__/test_masking_audit_and_import.cpython-313.pyc delete mode 100644 tests/__pycache__/test_module_boundaries.cpython-313-pytest-8.4.2.pyc delete mode 100644 tests/__pycache__/test_module_boundaries.cpython-313.pyc delete mode 100644 tests/__pycache__/test_performance.cpython-313-pytest-8.4.2.pyc delete mode 100644 tests/__pycache__/test_performance.cpython-313.pyc delete mode 100644 tests/__pycache__/test_smoke_contract.cpython-313-pytest-8.4.2.pyc delete mode 100644 tests/__pycache__/test_smoke_contract.cpython-313-pytest-9.0.2.pyc delete mode 100644 tests/__pycache__/test_smoke_contract.cpython-313.pyc delete mode 100644 tests/__pycache__/test_ui_redesign_bugs.cpython-313.pyc delete mode 100644 tests/__pycache__/test_verification_extractor.cpython-313-pytest-8.4.2.pyc delete mode 100644 tests/__pycache__/test_verification_extractor.cpython-313.pyc delete mode 100644 tests/_import_app.py delete mode 100644 tests/layout-system/README.md delete mode 100644 tests/layout-system/accessibility/layout-accessibility.test.js delete mode 100644 tests/layout-system/coverage/clover.xml delete mode 100644 tests/layout-system/coverage/coverage-final.json delete mode 100644 tests/layout-system/coverage/lcov-report/base.css delete mode 100644 tests/layout-system/coverage/lcov-report/block-navigation.js delete mode 100644 tests/layout-system/coverage/lcov-report/favicon.png delete mode 100644 tests/layout-system/coverage/lcov-report/index.html delete mode 100644 tests/layout-system/coverage/lcov-report/layout-manager.js.html delete mode 100644 tests/layout-system/coverage/lcov-report/prettify.css delete mode 100644 tests/layout-system/coverage/lcov-report/prettify.js delete mode 100644 tests/layout-system/coverage/lcov-report/sort-arrow-sprite.png delete mode 100644 tests/layout-system/coverage/lcov-report/sorter.js delete mode 100644 tests/layout-system/coverage/lcov-report/state-manager.js.html delete mode 100644 tests/layout-system/coverage/lcov.info delete mode 100644 tests/layout-system/integration/drag-resize.test.js delete mode 100644 tests/layout-system/integration/keyboard-resizer.test.js delete mode 100644 tests/layout-system/integration/panel-collapse.test.js delete mode 100644 tests/layout-system/integration/reset-layout.test.js delete mode 100644 tests/layout-system/integration/state-error-handling.test.js delete mode 100644 tests/layout-system/integration/state-load.test.js delete mode 100644 tests/layout-system/integration/state-multi-user.test.js delete mode 100644 tests/layout-system/integration/state-save.test.js delete mode 100644 tests/layout-system/integration/window-adapt.test.js delete mode 100644 tests/layout-system/jest.config.js delete mode 100644 tests/layout-system/performance/layout-performance.test.js delete mode 100644 tests/layout-system/setup.js delete mode 100644 tests/layout-system/test-utils.js delete mode 100644 tests/layout-system/unit/layout-manager.test.js delete mode 100644 tests/layout-system/unit/state-manager.test.js delete mode 100644 tests/test_api_extract_verification.py delete mode 100644 tests/test_core_features.py delete mode 100644 tests/test_distributed_lock.py delete mode 100644 tests/test_error_and_trace.py delete mode 100644 tests/test_frontend_manual.py delete mode 100644 tests/test_gptmail_error_handling.py delete mode 100644 tests/test_masking_audit_and_import.py delete mode 100644 tests/test_module_boundaries.py delete mode 100644 tests/test_performance.py delete mode 100644 tests/test_smoke_contract.py delete mode 100644 tests/test_ui_redesign_bugs.py delete mode 100644 tests/test_verification_extractor.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 088c5dc..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: Bug Report / Bug 报告 -about: Create a report to help us improve / 创建报告帮助我们改进 -title: "bug: " -labels: "bug" -assignees: "" ---- - -# Bug Report / Bug 报告 - -**Version / 版本:** - - - -**Current behavior / 当前行为:** - - - -**Expected behavior / 期望行为:** - - - -**Steps to reproduce / 复现步骤:** - - - -**Related code / 相关代码:** - - - -``` -insert short code snippets here / 在此插入简短代码片段 -``` - -**Other information / 其他信息:** - - diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 95cd90a..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: Feature Request / 功能请求 -about: Suggest an idea for this project / 为此项目提出想法 -title: "feat: " -labels: "enhancement" -assignees: "" ---- - -# Feature Request / 功能请求 - -**Describe the Feature Request / 描述功能请求** - - - -**Describe Preferred Solution / 描述首选解决方案** - - - -**Describe Alternatives / 描述替代方案** - - - -**Related Code / 相关代码** - - - -**Additional Context / 附加上下文** - - - -**If the feature request is approved, would you be willing to submit a PR? / 如果功能请求被批准,你愿意提交 PR 吗?** -Yes / No _(Help can be provided if you need assistance submitting a PR / 如果需要帮助提交 PR,我们可以提供协助)_ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 6059d4f..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,33 +0,0 @@ - - -## Pull request type / PR 类型 - - - -Please check the type of change your PR introduces / 请勾选 PR 引入的更改类型: - -- [ ] Bugfix / Bug 修复 -- [ ] Feature / 新功能 -- [ ] Code style update (formatting, renaming) / 代码风格更新(格式化、重命名) -- [ ] Refactoring (no functional changes, no api changes) / 重构(无功能更改、无 API 更改) -- [ ] Build related changes / 构建相关更改 -- [ ] Documentation content changes / 文档内容更改 -- [ ] Other (please describe) / 其他(请描述): - -## What is the current behavior? / 当前行为是什么? - - - -Issue Number / Issue 编号: N/A - -## What is the new behavior? / 新行为是什么? - - - -- -- -- - -## Other information / 其他信息 - - diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 2108459..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,40 +0,0 @@ -version: 2 -updates: - # Python dependencies - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 10 - labels: - - "dependencies" - - "python" - commit-message: - prefix: "chore" - include: "scope" - - # Docker dependencies - - package-ecosystem: "docker" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - labels: - - "dependencies" - - "docker" - commit-message: - prefix: "chore" - include: "scope" - - # GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - labels: - - "dependencies" - - "ci" - commit-message: - prefix: "chore" - include: "scope" diff --git a/.github/labels.yml b/.github/labels.yml deleted file mode 100644 index b67eaa8..0000000 --- a/.github/labels.yml +++ /dev/null @@ -1,84 +0,0 @@ ---- -- name: "breaking-change" - color: ee0701 - description: "A breaking change for existing users." -- name: "bug" - color: ee0701 - description: "Inconsistencies or issues which will cause a problem for users or implementors." -- name: "documentation" - color: 0052cc - description: "Solely about the documentation of the project." -- name: "enhancement" - color: 1d76db - description: "Enhancement of the code, not introducing new features." -- name: "refactor" - color: 1d76db - description: "Improvement of existing code, not introducing new features." -- name: "performance" - color: 1d76db - description: "Improving performance, not introducing new features." -- name: "new-feature" - color: 0e8a16 - description: "New features or options." -- name: "maintenance" - color: 2af79e - description: "Generic maintenance tasks." -- name: "ci" - color: 1d76db - description: "Work that improves the continue integration." -- name: "dependencies" - color: 1d76db - description: "Upgrade or downgrade of project dependencies." -- name: "python" - color: 3572A5 - description: "Related to Python code or dependencies." -- name: "docker" - color: 2496ED - description: "Related to Docker configuration or images." - -- name: "in-progress" - color: fbca04 - description: "Issue is currently being resolved by a developer." -- name: "stale" - color: fef2c0 - description: "There has not been activity on this issue or PR for quite some time." -- name: "no-stale" - color: fef2c0 - description: "This issue or PR is exempted from the stable bot." - -- name: "security" - color: ee0701 - description: "Marks a security issue that needs to be resolved ASAP." -- name: "incomplete" - color: fef2c0 - description: "Marks a PR or issue that is missing information." -- name: "invalid" - color: fef2c0 - description: "Marks a PR or issue that is missing information." - -- name: "beginner-friendly" - color: 0e8a16 - description: "Good first issue for people wanting to contribute to the project." -- name: "help-wanted" - color: 0e8a16 - description: "We need some extra helping hands or expertise in order to resolve this." - -- name: "priority-critical" - color: ee0701 - description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." -- name: "priority-high" - color: b60205 - description: "After critical issues are fixed, these should be dealt with before any further issues." -- name: "priority-medium" - color: 0e8a16 - description: "This issue may be useful, and needs some attention." -- name: "priority-low" - color: e4ea8a - description: "Nice addition, maybe... someday..." - -- name: "major" - color: b60205 - description: "This PR causes a major version bump in the version number." -- name: "minor" - color: 0e8a16 - description: "This PR causes a minor version bump in the version number." diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml deleted file mode 100644 index ffb402c..0000000 --- a/.github/workflows/code-quality.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Code Quality - -on: - push: - branches: [ "main", "master" ] - paths: - - "**.py" - - "requirements.txt" - - ".github/workflows/code-quality.yml" - pull_request: - paths: - - "**.py" - - "requirements.txt" - - ".github/workflows/code-quality.yml" - workflow_dispatch: - -jobs: - linting: - name: Code Linting - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 black isort mypy - - - name: Run Black (Code Formatter Check) - run: black --check --diff . - - - name: Run isort (Import Sorting Check) - run: isort --check-only --diff . - - - name: Run Flake8 (Linting) - run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - - - name: Run Flake8 (Complexity Check) - run: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - security: - name: Security Scan - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - actions: read - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install bandit[toml] - - - name: Run Bandit Security Scan - run: bandit -r . -f json -o bandit-report.json || true - - - name: Display Bandit Results - run: | - if [ -f bandit-report.json ]; then - cat bandit-report.json - fi diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml deleted file mode 100644 index fd265fa..0000000 --- a/.github/workflows/docker-build-push.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Build and Push Docker Image (Docker Hub) - -on: - push: - branches: - - main - - master - paths: - - '**.py' - - 'requirements.txt' - - 'Dockerfile' - - 'templates/**' - workflow_dispatch: - -env: - # 统一方案一:发布到我们可控的 Docker Hub 账号命名空间 - DOCKERHUB_IMAGE: guangshanshui/outlook-email-plus - -jobs: - build-and-push: - # 未配置 Docker Hub 凭据时跳过,避免在 fork/未配置 secrets 场景失败 - if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.DOCKERHUB_IMAGE }} - tags: | - type=ref,event=branch - type=sha,prefix={{branch}}- - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: linux/amd64,linux/arm64 - - - name: Image digest - run: echo ${{ steps.meta.outputs.digest }} diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml deleted file mode 100644 index 9e350e8..0000000 --- a/.github/workflows/python-tests.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Python Tests - -on: - push: - branches: [ "main", "master" ] - paths: - - "**.py" - - "requirements.txt" - - "tests/**" - - ".github/workflows/python-tests.yml" - pull_request: - paths: - - "**.py" - - "requirements.txt" - - "tests/**" - - ".github/workflows/python-tests.yml" - workflow_dispatch: - -jobs: - tests: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: [ "3.11", "3.12", "3.13" ] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements.txt - - - name: Syntax check - run: | - python -m py_compile web_outlook_app.py - python -m py_compile outlook_mail_reader.py - - - name: Run unit tests - run: python -m unittest discover -s tests -v - diff --git a/.gitignore b/.gitignore index a1e8dc4..79b1c5b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,20 +21,16 @@ data/ # 环境变量 .env -# IDE +# IDE 配置 .vscode/ .idea/ *.swp *.swo .kiro/ -# Node / Jest -node_modules/ -package.json -package-lock.json -tests/layout-system/coverage/ -tests/layout-system/accessibility/ -tests/layout-system/performance/ +# 测试文件(不开源) +tests/ +.pytest_cache/ # 文档目录(不开源) docs/ @@ -42,29 +38,34 @@ PRD/ 我们的文档/ img/ -# 临时文件和迁移痕迹 +# GitHub 配置(不开源) +.github/ + +# 项目文档(不开源) +CONTRIBUTING.md +SECURITY.md + +# Node.js +node_modules/ +package.json +package-lock.json + +# 临时文件 +*.log +*.tmp +.DS_Store +Thumbs.db +.mcp.json +.ace-tool/ +.claude/ + +# 其他不需要的文件 PixPin_*.png exeample/ ui-preview.html -.mcp.json AI_EXECUTION_PROMPT.md AI工作提示词*.md *api-key-credit-monitoring.md - -# 日志 -*.log - -# 系统文件 -.DS_Store -Thumbs.db -.pytest_cache/ - -# 配置文件(不开源) -/get_oauth_token.py -/test* -!/tests/ -!/tests/** -.ace-tool/ -/CLAUDE.md -/.claude/ +CLAUDE.md 安全漏洞.md +get_oauth_token.py diff --git a/.kiro/steering/architecture.md b/.kiro/steering/architecture.md deleted file mode 100644 index b3405d3..0000000 --- a/.kiro/steering/architecture.md +++ /dev/null @@ -1,366 +0,0 @@ -# 架构设计 - -## 整体架构 - -### 架构模式 -采用经典的三层架构模式: - -``` -┌─────────────────────────────────────────┐ -│ Presentation Layer │ -│ (Routes / Templates / Static) │ -└─────────────────┬───────────────────────┘ - │ -┌─────────────────▼───────────────────────┐ -│ Business Layer │ -│ (Services) │ -└─────────────────┬───────────────────────┘ - │ -┌─────────────────▼───────────────────────┐ -│ Data Access Layer │ -│ (Repositories) │ -└─────────────────┬───────────────────────┘ - │ -┌─────────────────▼───────────────────────┐ -│ Database Layer │ -│ (SQLite) │ -└─────────────────────────────────────────┘ -``` - -## 目录结构详解 - -### outlook_web/ 主模块 - -#### 核心文件 -- `app.py` - Flask 应用工厂,负责应用初始化和 Blueprint 注册 -- `config.py` - 配置管理,环境变量读取 -- `db.py` - 数据库连接管理,Schema 初始化和升级 -- `audit.py` - 审计日志记录 -- `errors.py` - 错误处理和 trace_id 生成 - -#### routes/ 路由层 -Blueprint 模块化路由,每个文件对应一个功能模块 (共 11 个): - -- `pages.py` - 页面路由 (首页/登录页) -- `accounts.py` - 邮箱账号 CRUD -- `groups.py` - 分组 CRUD -- `tags.py` - 标签 CRUD -- `emails.py` - 邮件查看/删除 -- `temp_emails.py` - 临时邮箱管理 -- `oauth.py` - OAuth2 授权流程 -- `settings.py` - 系统设置 -- `scheduler.py` - 定时任务管理 -- `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 调用 (共 9 个): - -- `graph.py` - Microsoft Graph API 封装 - - 获取邮件列表 - - 获取邮件详情 - - 刷新 Token - - 删除邮件 -- `imap.py` - IMAP 协议封装 - - 新版服务器 (outlook.live.com) - - 旧版服务器 (outlook.office365.com) - - 邮件列表/详情获取 -- `refresh.py` - Token 刷新服务 - - 单账号刷新 - - 批量刷新 - - 定时刷新调度 -- `email_delete.py` - 邮件删除服务 - - 多 API 回退策略 -- `scheduler.py` - 定时任务调度器 - - APScheduler 封装 - - 任务管理 -- `gptmail.py` - GPTMail API 封装 -- `http.py` - HTTP 客户端封装 (支持代理) -- `verification_extractor.py` - 验证码提取服务 - - 验证码/链接自动提取 - - Graph API → IMAP 回退策略 - - 正则表达式匹配 - -#### repositories/ 数据访问层 -数据库 CRUD 操作封装 (共 8 个): - -- `accounts.py` - 邮箱账号数据访问 - - 增删改查 - - 批量操作 - - 状态更新 -- `groups.py` - 分组数据访问 -- `tags.py` - 标签数据访问 -- `settings.py` - 系统设置数据访问 -- `temp_emails.py` - 临时邮箱数据访问 -- `refresh_logs.py` - 刷新日志数据访问 -- `refresh_runs.py` - 刷新运行记录数据访问 -- `distributed_locks.py` - 分布式锁实现 - -#### middleware/ 中间件 -横切关注点处理 (共 2 个): - -- `trace.py` - trace_id 中间件 - - 请求追踪 ID 生成 - - 日志关联 -- `error_handler.py` - 错误处理中间件 - - 统一错误响应 - - 异常捕获 - -#### security/ 安全模块 -安全相关功能封装 (共 3 个): - -- `auth.py` - 认证授权 - - 登录验证 - - 密码哈希 - - 会话管理 - - 登录限流 -- `crypto.py` - 加密解密 - - Fernet 对称加密 - - Token 加密存储 - - 密钥派生 -- `csrf.py` - CSRF 防护 - - Token 生成和验证 - - 装饰器封装 - -## 数据流 - -### 典型请求流程 - -``` -1. 用户请求 - ↓ -2. Route (Blueprint) - - 参数验证 - - CSRF 验证 - - 认证检查 - ↓ -3. Service - - 业务逻辑 - - 外部 API 调用 - - 数据处理 - ↓ -4. Repository - - 数据库操作 - - 数据转换 - ↓ -5. Database - - SQL 执行 - ↓ -6. 响应返回 - - JSON / HTML - - 错误处理 -``` - -### 示例: 获取邮件列表 - -```python -# 1. Route (routes/emails.py) -@bp.route("/api/emails") -def get_emails(): - account_id = request.args.get("account_id") - folder = request.args.get("folder", "inbox") - - # 2. Service (services/graph.py or imap.py) - emails = email_service.get_emails(account_id, folder) - - return jsonify(emails) - -# 3. Service 内部 -def get_emails(account_id, folder): - # 从 Repository 获取账号信息 - account = account_repo.get_by_id(account_id) - - # 尝试 Graph API - try: - return graph_api.get_emails(account, folder) - except: - # 回退到 IMAP - return imap_api.get_emails(account, folder) -``` - -## 设计模式 - -### 1. 工厂模式 -`create_app()` 应用工厂函数,便于测试和多实例创建。 - -### 2. 仓储模式 -Repository 层封装数据访问,业务逻辑与数据库解耦。 - -### 3. 策略模式 -邮件访问采用多策略回退 (Graph API → IMAP 新 → IMAP 旧)。 - -### 4. 装饰器模式 -- `@login_required` - 登录验证 -- `@csrf_exempt` - CSRF 豁免 -- `@rate_limit` - 速率限制 - -### 5. 单例模式 -数据库连接绑定到 Flask `g` 对象,请求内单例。 - -## 安全架构 - -### 多层防护 - -``` -┌─────────────────────────────────────────┐ -│ Input Validation │ 参数验证 -└─────────────────┬───────────────────────┘ - │ -┌─────────────────▼───────────────────────┐ -│ CSRF Protection │ CSRF Token -└─────────────────┬───────────────────────┘ - │ -┌─────────────────▼───────────────────────┐ -│ Authentication & Rate Limit │ 登录验证/限流 -└─────────────────┬───────────────────────┘ - │ -┌─────────────────▼───────────────────────┐ -│ Business Logic │ 业务逻辑 -└─────────────────┬───────────────────────┘ - │ -┌─────────────────▼───────────────────────┐ -│ Data Encryption │ 数据加密 -└─────────────────┬───────────────────────┘ - │ -┌─────────────────▼───────────────────────┐ -│ Audit Logging │ 审计日志 -└─────────────────────────────────────────┘ -``` - -### XSS 防护 -1. **前端**: DOMPurify 净化 HTML -2. **iframe 沙箱**: 禁止脚本执行 -3. **输出转义**: Jinja2 自动转义 - -### CSRF 防护 -1. **Token 验证**: 所有状态变更操作 -2. **SameSite Cookie**: Lax 模式 -3. **Referer 检查**: 可选 - -### 数据加密 -1. **Refresh Token**: Fernet 对称加密 -2. **密码**: bcrypt 哈希 -3. **密钥派生**: 基于 SECRET_KEY - -## 并发处理 - -### 数据库并发 -- SQLite WAL 模式 -- 连接池管理 (Flask g 对象) -- 分布式锁 (distributed_locks 表) - -### 定时任务 -- APScheduler 后台调度 -- 单实例运行 (避免重复执行) -- 任务状态持久化 - -## 缓存策略 - -### 邮件列表缓存 -- 前端内存缓存 -- 切换邮箱/文件夹即时展示 -- 刷新页面清空缓存 - -### Token 缓存 -- 数据库持久化 -- 90 天有效期 -- 自动刷新机制 - -## 错误处理 - -### 统一错误响应 -```json -{ - "error": "错误信息", - "trace_id": "唯一追踪 ID", - "details": "详细错误信息" -} -``` - -### 错误级别 -1. **HTTP 异常**: 4xx/5xx 状态码 -2. **业务异常**: 自定义错误类 -3. **系统异常**: 未捕获异常 - -### 错误追踪 -- 每个请求生成唯一 trace_id -- 记录到审计日志 -- 便于问题排查 - -## 扩展性设计 - -### 水平扩展 -- 无状态设计 (会话存储在 Cookie) -- 数据库可迁移到 PostgreSQL/MySQL -- 支持负载均衡 - -### 垂直扩展 -- 模块化设计,易于拆分微服务 -- Blueprint 可独立部署 -- 服务层可抽取为独立服务 - -## 前端架构 - -### 静态资源结构 -``` -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. ✅ 中间件统一处理 - -### 下一步计划 -- ⏳ 前后端完全分离 (RESTful API) -- ⏳ 前端框架引入 (Vue.js/React) -- ⏳ API 文档完善 (OpenAPI/Swagger) diff --git a/.kiro/steering/project-overview.md b/.kiro/steering/project-overview.md deleted file mode 100644 index f600440..0000000 --- a/.kiro/steering/project-overview.md +++ /dev/null @@ -1,223 +0,0 @@ -# 项目概览 - -## 项目名称 -Outlook 邮件管理工具 (Outlook Email Management Tool) - -## 项目类型 -Web 应用 - Flask 后端 + 传统前端 - -## 技术栈 - -### 后端框架 -- **Flask 3.0+** - Web 框架 -- **Flask-WTF 1.2+** - CSRF 保护 -- **Werkzeug 3.0+** - WSGI 工具库 - -### 数据存储 -- **SQLite** - 轻量级关系数据库 -- 数据库路径: `data/outlook_accounts.db` -- Schema 版本: v2 - -### 核心依赖 -- **requests[socks] 2.25+** - HTTP 客户端,支持 SOCKS 代理 -- **APScheduler 3.10+** - 定时任务调度 -- **croniter 1.3+** - Cron 表达式解析 -- **bcrypt 4.0+** - 密码哈希 -- **cryptography 41.0+** - 数据加密 (Fernet 对称加密) - -### 外部 API -- **Microsoft Graph API** - 主要邮件访问方式 -- **IMAP 协议** - 备用邮件访问方式 (新版/旧版服务器) -- **GPTMail API** - 临时邮箱服务 - -## 项目结构 - -``` -outlookEmail/ -├── web_outlook_app.py # 应用入口 -├── outlook_mail_reader.py # 命令行邮件读取工具 -├── outlook_web/ # 主应用模块 -│ ├── app.py # Flask 应用工厂 -│ ├── config.py # 配置管理 -│ ├── db.py # 数据库操作 -│ ├── audit.py # 审计日志 -│ ├── errors.py # 错误处理 -│ ├── routes/ # 路由层 (Blueprint) -│ ├── controllers/ # 控制器层 -│ ├── services/ # 服务层 -│ ├── repositories/ # 数据访问层 -│ ├── security/ # 安全模块 -│ └── middleware/ # 中间件 -├── templates/ # HTML 模板 -│ ├── index.html # 主页面 -│ ├── login.html # 登录页 -│ └── partials/ # 页面片段 -├── static/ # 静态资源 -│ ├── css/ # 样式文件 -│ └── js/ # JavaScript 文件 -│ ├── features/ # 功能模块 -│ └── layout-*.js # 布局系统 -├── tests/ # 测试 -├── docs/ # 文档 -└── data/ # 数据目录 -``` - -## 核心功能模块 - -### 1. 邮箱账号管理 -- 多邮箱账号管理 -- 分组管理 (支持自定义颜色) -- 标签管理 (批量打标签/筛选) -- 批量导入/导出 -- 分组级别代理配置 - -### 2. 邮件操作 -- 查看邮件 (收件箱/垃圾邮件/已删除) -- 删除邮件 (单封/批量) -- 全屏查看模式 -- 分页加载 (每页 20 封) -- 邮件缓存机制 - -### 3. Token 管理 -- OAuth2 授权流程 -- 自动刷新 Token -- 定时刷新 (按天数/Cron 表达式) -- 刷新历史记录 -- 失败状态标识 - -### 4. 临时邮箱 -- 集成 GPTMail API -- 一键生成临时邮箱 - -### 5. 批量操作增强 -- 全选功能 (跨分组保持选中状态) -- 批量删除邮箱 -- 批量移动分组 -- 批量打标签 - -### 6. 验证码提取 -- 自动提取验证码/链接 -- Graph API → IMAP 回退策略 -- 一键复制到剪贴板 -- 加载状态与成功/失败反馈 - -### 7. 自动轮询 -- 新邮件自动检测 -- 桌面通知 (邮箱地址 + 邮件主题) -- 账号列表红点提�� -- 连续错误自动停止 -- 可配置轮询间隔/次数 - -### 8. 可调整布局系统 -- 四栏式响应式布局 -- 拖拽调整栏宽 -- 布局状态持久化 -- 支持折叠/展开 -- 自适应窗口大小 - -### 9. 安全特性 -- XSS 防护 (DOMPurify + iframe 沙箱) -- CSRF 防护 (Flask-WTF) -- 数据加密 (Fernet 对称加密) -- 登录限流 (5 次失败锁定 15 分钟) -- 审计日志 -- 二次验证 (导出功能) - -## 架构特点 - -### 分层架构 -``` -Routes (路由层) - ↓ -Services (服务层) - ↓ -Repositories (数据访问层) - ↓ -Database (SQLite) -``` - -### Blueprint 模块化 -- `pages` - 页面路由 -- `accounts` - 邮箱账号管理 -- `groups` - 分组管理 -- `tags` - 标签管理 -- `emails` - 邮件操作 -- `temp_emails` - 临时邮箱 -- `oauth` - OAuth2 授权 -- `settings` - 系统设置 -- `scheduler` - 定时任务 -- `system` - 系统操作 -- `audit` - 审计日志 - -### 应用工厂模式 -使用 `create_app()` 工厂函数创建 Flask 应用实例,便于测试和配置管理。 - -### 完整分层架构 -- 已完成从单体架构到分层架构的迁移 -- Routes → Controllers → Services → Repositories 清晰分层 -- 中间件统一处理横切关注点 - -## 数据模型 - -### 核心表 -- `accounts` - 邮箱账号 -- `groups` - 分组 -- `tags` - 标签 -- `account_tags` - 账号标签关联 -- `settings` - 系统设置 -- `temp_emails` - 临时邮箱 -- `refresh_logs` - Token 刷新日志 -- `refresh_runs` - 刷新运行记录 -- `distributed_locks` - 分布式锁 - -## 部署方式 - -### 开发环境 -```bash -python web_outlook_app.py -``` - -### 生产环境 -- Docker 部署 (提供 Dockerfile) -- WSGI 服务器 (Gunicorn/uWSGI) -- 环境变量配置 - -### 环境变量 -- `PORT` - 端口号 (默认 5000) -- `HOST` - 监听地址 (默认 0.0.0.0) -- `FLASK_ENV` - 运行模式 (production/development) -- `SECRET_KEY` - Flask 密钥 -- `DATABASE_PATH` - 数据库路径 - -## API 优先级策略 - -邮件操作采用三级回退机制: -1. **Graph API** (主要方式) -2. **IMAP 新版** (outlook.live.com) -3. **IMAP 旧版** (outlook.office365.com) - -失败时自动回退到下一级,并提供详细错误信息。 - -## 当前状态 - -### 已完成 -- ✅ 核心功能实现 -- ✅ 安全特性完善 -- ✅ 分组代理支持 -- ✅ API 优先级回退 -- ✅ 详细错误提示 -- ✅ 全选功能 (跨分组状态保持) -- ✅ 验证码提取 (Graph → IMAP 回退) -- ✅ 自动轮询与通知 -- ✅ 批量删除邮箱 -- ✅ 架构迁移完成 (legacy.py 已移除) -- ✅ 可调整布局系统 - -### 进行中 -- 🔄 前后端分离准备 -- 🔄 单元测试覆盖率提升 - -### 待优化 -- ⏳ 前后端完全分离 -- ⏳ API 文档完善 -- ⏳ 性能优化与监控 diff --git a/.kiro/steering/tech-stack.md b/.kiro/steering/tech-stack.md deleted file mode 100644 index 0d7f31c..0000000 --- a/.kiro/steering/tech-stack.md +++ /dev/null @@ -1,345 +0,0 @@ -# 技术栈详解 - -## 后端技术 - -### Web 框架 -- **Flask 3.0+** - - 轻量级 WSGI Web 框架 - - 灵活的扩展系统 - - 应用工厂模式支持 - - Blueprint 模块化 - -### 数据库 -- **SQLite** - - 轻量级嵌入式数据库 - - 无需独立服务器 - - 支持 WAL 模式 (并发优化) - - 适合中小规模应用 - -### 安全库 -- **Flask-WTF 1.2+** - - CSRF 保护 - - 表单验证 - - 文件上传处理 - -- **bcrypt 4.0+** - - 密码哈希算法 - - 自适应成本因子 - - 防止彩虹表攻击 - -- **cryptography 41.0+** - - Fernet 对称加密 - - 密钥派生 (PBKDF2) - - Token 加密存储 - -### HTTP 客户端 -- **requests[socks] 2.25+** - - HTTP/HTTPS 请求 - - SOCKS5 代理支持 - - 会话管理 - - 连接池 - -### 定时任务 -- **APScheduler 3.10+** - - 后台任务调度 - - Cron 表达式支持 - - 持久化任务状态 - - 多种触发器 (interval/cron/date) - -- **croniter 1.3+** - - Cron 表达式解析 - - 下次执行时间计算 - -### WSGI 服务器 -- **Werkzeug 3.0+** - - Flask 底层 WSGI 工具库 - - 开发服务器 - - 调试工具 - - 中间件支持 - -## 前端技术 - -### 基础技术 -- **HTML5** - - 语义化标签 - - 表单验证 - -- **CSS3** - - Flexbox 布局 - - 响应式设计 - - 动画效果 - -- **JavaScript (ES6+)** - - 异步请求 (Fetch API) - - 事件处理 - - DOM 操作 - -### 第三方库 -- **jQuery 3.x** - - DOM 操作简化 - - AJAX 请求 - - 事件处理 - -- **DOMPurify** - - HTML 净化 - - XSS 防护 - - 白名单过滤 - -### UI 组件 -- 自定义 CSS 样式 -- 四栏式布局 -- 响应式设计 - -## 外部 API - -### Microsoft Graph API -- **用途**: 主要邮件访问方式 -- **认证**: OAuth2 -- **功能**: - - 获取邮件列表 - - 获取邮件详情 - - 删除邮件 - - 刷新 Token - -### IMAP 协议 -- **用途**: 备用邮件访问方式 -- **服务器**: - - 新版: `outlook.live.com:993` - - 旧版: `outlook.office365.com:993` -- **认证**: 邮箱密码 -- **功能**: - - 获取邮件列表 - - 获取邮件详情 - -### GPTMail API -- **用途**: 临时邮箱服务 -- **功能**: - - 生成临时邮箱 - - 获取临时邮件 - -## 开发工具 - -### 包管理 -- **pip** - - Python 包管理器 - - requirements.txt 依赖管理 - -### 测试框架 -- **pytest** - - 单元测试 - - 集成测试 - - 覆盖率报告 - -### 代码质量 -- **类型注解** - - `from __future__ import annotations` - - 类型提示 - -### 版本控制 -- **Git** - - 分支管理 - - 提交规范 - -## 部署技术 - -### 容器化 -- **Docker** - - 容器化部署 - - 环境隔离 - - 快速部署 - -### Web 服务器 -- **Nginx** (推荐) - - 反向代理 - - 静态文件服务 - - HTTPS 配置 - -### WSGI 服务器 -- **Gunicorn** (推荐) - - 多进程模式 - - 性能优化 - - 平滑重启 - -- **uWSGI** (备选) - - 高性能 - - 多协议支持 - -## 数据库设计 - -### 表结构 - -#### accounts (邮箱账号) -```sql -CREATE TABLE accounts ( - id INTEGER PRIMARY KEY, - email TEXT NOT NULL, - password TEXT, - refresh_token TEXT, - group_id INTEGER, - status TEXT, - remark TEXT, - created_at TIMESTAMP, - updated_at TIMESTAMP -) -``` - -#### groups (分组) -```sql -CREATE TABLE groups ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - color TEXT, - proxy_type TEXT, - proxy_host TEXT, - proxy_port INTEGER, - created_at TIMESTAMP -) -``` - -#### tags (标签) -```sql -CREATE TABLE tags ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - created_at TIMESTAMP -) -``` - -#### account_tags (账号标签关联) -```sql -CREATE TABLE account_tags ( - account_id INTEGER, - tag_id INTEGER, - PRIMARY KEY (account_id, tag_id) -) -``` - -#### settings (系统设置) -```sql -CREATE TABLE settings ( - key TEXT PRIMARY KEY, - value TEXT, - updated_at TIMESTAMP -) -``` - -#### refresh_logs (刷新日志) -```sql -CREATE TABLE refresh_logs ( - id INTEGER PRIMARY KEY, - run_id TEXT, - account_id INTEGER, - status TEXT, - error_message TEXT, - created_at TIMESTAMP -) -``` - -#### refresh_runs (刷新运行记录) -```sql -CREATE TABLE refresh_runs ( - id TEXT PRIMARY KEY, - total_count INTEGER, - success_count INTEGER, - failed_count INTEGER, - started_at TIMESTAMP, - completed_at TIMESTAMP -) -``` - -#### distributed_locks (分布式锁) -```sql -CREATE TABLE distributed_locks ( - lock_name TEXT PRIMARY KEY, - owner TEXT, - acquired_at TIMESTAMP, - expires_at TIMESTAMP -) -``` - -#### temp_emails (临时邮箱) -```sql -CREATE TABLE temp_emails ( - id INTEGER PRIMARY KEY, - email TEXT NOT NULL, - created_at TIMESTAMP -) -``` - -## 性能优化 - -### 数据库优化 -- 索引优化 (email, group_id, status) -- 连接池管理 -- WAL 模式 (并发优化) -- 分布式锁 (避免竞态条件) - -### 缓存策略 -- 前端邮件列表缓存 -- Token 数据库持久化 -- 静态资源缓存 - -### 并发处理 -- Flask g 对象 (请求级单例) -- APScheduler 后台调度 -- 分布式锁机制 - -## 安全技术 - -### 认证授权 -- 会话管理 (Flask Session) -- 密码哈希 (bcrypt) -- OAuth2 授权流程 - -### 数据保护 -- Fernet 对称加密 -- 密钥派生 (PBKDF2) -- HTTPS 传输加密 - -### 攻击防护 -- XSS 防护 (DOMPurify + 输出转义) -- CSRF 防护 (Flask-WTF) -- SQL 注入防护 (参数化查询) -- 登录限流 (速率限制) - -### 审计追踪 -- 操作日志记录 -- trace_id 追踪 -- 敏感操作审计 - -## 监控与日志 - -### 日志系统 -- Python logging 模块 -- 分级日志 (DEBUG/INFO/WARNING/ERROR) -- 文件日志轮转 - -### 错误追踪 -- 统一 trace_id -- 错误详情记录 -- 审计日志 - -### 性能监控 -- 请求响应时间 -- 数据库查询性能 -- 定时任务执行状态 - -## 扩展性 - -### 水平扩展 -- 无状态设计 -- 数据库可迁移 (PostgreSQL/MySQL) -- 负载均衡支持 - -### 垂直扩展 -- 模块化设计 -- Blueprint 独立部署 -- 服务层可抽取为微服务 - -### 未来技术栈 -- 前后端分离 (Vue.js/React) -- RESTful API -- PostgreSQL/MySQL -- Redis 缓存 -- Celery 异步任务 -- Docker Compose 编排 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index d45f992..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,149 +0,0 @@ -# Contributing to Outlook Email Management Tool / 为 Outlook 邮件管理工具做贡献 - -First off, thank you for considering contributing to this project! / 首先,感谢你考虑为此项目做贡献! - -The following is a set of guidelines for contributing. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. / 以下是贡献指南。这些主要是指导方针,而非规则。请运用你的最佳判断,并随时在 PR 中提出对本文档的修改建议。 - -## Table of Contents / 目录 - -- [Code of Conduct](#code-of-conduct--行为准则) -- [How Can I Contribute?](#how-can-i-contribute--我如何做贡献) -- [Development Setup](#development-setup--开发环境设置) -- [Pull Request Process](#pull-request-process--pr-流程) -- [Style Guidelines](#style-guidelines--风格指南) - -## Code of Conduct / 行为准则 - -This project and everyone participating in it is governed by our Code of Conduct. By participating, you are expected to uphold this code. / 此项目及其所有参与者均受我们的行为准则约束。参与即表示你同意遵守此准则。 - -## How Can I Contribute? / 我如何做贡献? - -### Reporting Bugs / 报告 Bug - -Before creating bug reports, please check the existing issues as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible: / 在创建 bug 报告之前,请检查现有 issue,你可能会发现无需创建新的。创建 bug 报告时,请尽可能包含详细信息: - -- Use a clear and descriptive title / 使用清晰描述性的标题 -- Describe the exact steps to reproduce the problem / 描述重现问题的确切步骤 -- Provide specific examples / 提供具体示例 -- Describe the behavior you observed and what you expected / 描述你观察到的行为以及你的期望 -- Include screenshots if possible / 如可能,包含截图 -- Include your environment details (OS, Python version, etc.) / 包含你的环境详情(操作系统、Python 版本等) - -### Suggesting Enhancements / 建议增强功能 - -Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include: / 增强建议通过 GitHub issue 跟踪。创建增强建议时,请包含: - -- Use a clear and descriptive title / 使用清晰描述性的标题 -- Provide a detailed description of the suggested enhancement / 提供建议增强功能的详细描述 -- Explain why this enhancement would be useful / 解释为什么此增强功能有用 -- List some examples of how it would be used / 列出一些使用示例 - -### Your First Code Contribution / 你的第一次代码贡献 - -Unsure where to begin? You can start by looking through `beginner-friendly` and `help-wanted` issues: / 不确定从哪里开始?你可以从 `beginner-friendly` 和 `help-wanted` issue 开始: - -- Beginner-friendly issues - issues which should only require a few lines of code / 初学者友好的 issue - 只需几行代码即可解决 -- Help wanted issues - issues which may be more involved / 需要帮助的 issue - 可能涉及更多内容 - -## Development Setup / 开发环境设置 - -1. Fork the repository / Fork 仓库 -2. Clone your fork / 克隆你的 fork - ```bash - git clone https://github.com/YOUR_USERNAME/outlookEmail.git - cd outlookEmail - ``` - -3. Create a virtual environment / 创建虚拟环境 - ```bash - python -m venv venv - source venv/bin/activate # On Windows: venv\Scripts\activate - ``` - -4. Install dependencies / 安装依赖 - ```bash - pip install -r requirements.txt - ``` - -5. Create a branch for your changes / 为你的更改创建分支 - ```bash - git checkout -b feature/your-feature-name - ``` - -6. Make your changes and test them / 进行更改并测试 - ```bash - python -m unittest discover -s tests -v - ``` - -## Pull Request Process / PR 流程 - -1. Ensure your code follows the project's coding standards / 确保你的代码遵循项目的编码标准 -2. Update the documentation if needed / 如需要,更新文档 -3. Add tests for new features / 为新功能添加测试 -4. Ensure all tests pass / 确保所有测试通过 -5. Update the README.md with details of changes if applicable / 如适用,更新 README.md 中的更改详情 -6. Create a Pull Request with a clear title and description / 创建带有清晰标题和描述的 PR -7. Link any related issues in the PR description / 在 PR 描述中链接相关 issue - -### PR Title Format / PR 标题格式 - -Use conventional commit format: / 使用约定式提交格式: - -- `feat: Add new feature` / `feat: 添加新功能` -- `fix: Fix bug in email fetching` / `fix: 修复邮件获取中的 bug` -- `docs: Update README` / `docs: 更新 README` -- `style: Format code` / `style: 格式化代码` -- `refactor: Refactor authentication` / `refactor: 重构认证` -- `test: Add tests for email service` / `test: 为邮件服务添加测试` -- `chore: Update dependencies` / `chore: 更新依赖` - -## Style Guidelines / 风格指南 - -### Python Code Style / Python 代码风格 - -- Follow PEP 8 style guide / 遵循 PEP 8 风格指南 -- Use meaningful variable and function names / 使用有意义的变量和函数名 -- Add docstrings to functions and classes / 为函数和类添加文档字符串 -- Keep functions small and focused / 保持函数小而专注 -- Use type hints where appropriate / 在适当的地方使用类型提示 - -### Git Commit Messages / Git 提交消息 - -- Use the present tense ("Add feature" not "Added feature") / 使用现在时("Add feature" 而非 "Added feature") -- Use the imperative mood ("Move cursor to..." not "Moves cursor to...") / 使用祈使语气("Move cursor to..." 而非 "Moves cursor to...") -- Limit the first line to 72 characters or less / 第一行限制在 72 个字符以内 -- Reference issues and pull requests after the first line / 在第一行之后引用 issue 和 PR - -### Testing / 测试 - -- Write unit tests for new features / 为新功能编写单元测试 -- Ensure all tests pass before submitting PR / 提交 PR 前确保所有测试通过 -- Aim for high test coverage / 追求高测试覆盖率 -- Test edge cases and error conditions / 测试边界情况和错误条件 - -## Project Structure / 项目结构 - -``` -outlook_web/ -├── app.py # Application factory -├── routes/ # Blueprint routes -├── controllers/ # Request handlers -├── services/ # Business logic -├── repositories/ # Data access -├── middleware/ # Middleware -└── security/ # Security modules -``` - -For detailed architecture information, see `docs/DEV/00002-前后端拆分-开发者指南.md` / 详细架构信息请参见 `docs/DEV/00002-前后端拆分-开发者指南.md` - -## Questions? / 有问题? - -Feel free to open an issue with your question or reach out to the maintainers. / 随时开启 issue 提问或联系维护者。 - -## License / 许可证 - -By contributing, you agree that your contributions will be licensed under the Apache License 2.0. / 通过贡献,你同意你的贡献将在 Apache License 2.0 下授权。 - ---- - -Thank you for contributing! / 感谢你的贡献! diff --git a/README.md b/README.md index ffcf003..65e31ba 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,85 @@ # Outlook Email Plus -一个功能完整的 Outlook 邮件管理解决方案,支持多种方式读取 Outlook 邮箱邮件,并提供现代化 Web 界面进行邮箱账号管理和邮件查看。 +> 一个功能完整的 Outlook 邮件管理解决方案,支持多账号管理、多种邮件读取方式和现代化 Web 界面。 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![Docker](https://img.shields.io/badge/docker-ready-brightgreen.svg)](https://hub.docker.com/r/guangshanshui/outlook-email-plus) -## ✨ 功能特性 +## ✨ 核心特性 -### 邮件读取方式 -本工具支持三种方式读取 Outlook 邮箱邮件: - -1. **旧版 IMAP 方式** - 使用 `outlook.office365.com` 服务器 -2. **新版 IMAP 方式** - 使用 `outlook.live.com` 服务器 -3. **Graph API 方式** - 使用 Microsoft Graph API(推荐) - -### Web 应用功能 - -#### 核心功能 -- 🔐 **登录验证** - 密码保护的 Web 界面,支持在线修改密码 -- 📁 **分组管理** - 支持创建、编辑、删除邮箱分组,自定义分组颜色,支持分组级别代理设置(⭐ 2026-02-18 新增) -- 🌐 **分组代理** - 每个分组可配置 HTTP/SOCKS5 代理,分组下所有邮箱的 Graph API 请求走该代理(⭐ 2026-02-18 新增),走本项目获取的token均为Graph API -- 📧 **多邮箱管理** - 批量导入和管理多个 Outlook 邮箱账号 -- 📬 **邮件查看** - 查看收件箱、垃圾邮件和已删除邮件 -- 🔍 **全屏查看** - 支持全屏模式查看邮件,完整展示长邮件内容(⭐ 2026-01-23 新增) -- 📤 **导出功能** - 支持按分组或全部导出邮箱账号信息,二次验证保护(⭐ 2026-01-26 增强) -- 🎨 **现代化 UI** - 简洁美观的四栏式界面布局 -- ⚡ **性能优化** - 智能缓存机制,快速切换分组和邮箱 -- 📄 **分页加载** - 邮件列表支持滚动到底部自动加载下一页(每页20封) -- 🗑️ **多文件夹支持** - 支持查看收件箱、垃圾邮件、已删除邮件 -- 🔥 **临时邮箱** - 集成 GPTMail API,一键生成临时邮箱 -- ⚙️ **系统设置** - 支持在线修改登录密码和 GPTMail API Key -- 🔄 **OAuth2 助手** - 内置 OAuth2 授权流程,快速获取 Refresh Token -- 💾 **邮件缓存** - 智能缓存邮件列表,切换邮箱/文件夹即时展示,刷新页面清空缓存(⭐ 2026-01-29 新增) -- 🔓 **信任邮件模式** - 支持查看原始邮件内容,可选择绕过安全过滤(⭐ 2026-01-29 新增) -- 🏷️ **标签管理** - 支持给邮箱账号打标签,支持批量打标签/去标签,支持按标签筛选(⭐ 2026-01-31 新增) -- 📦 **批量移动分组** - 支持批量选择复个邮箱账号并移动到指定分组(⭐ 2026-02-08 新增) -- 🗑️ **邮件删除** - 支持单封/批量永久删除邮件,二次确认防误删(⭐ 2026-01-31 新增),注意:买的号大概率是没有开删除的api权限的 所以删除大概率会失败,自己授权的删除应该无问题 -- 🔄 **API 优先级回退** - 获取邮件/刷新Token/删除邮件均按 Graph API → IMAP(新) → IMAP(旧) 优先级自动回退(⭐ 2026-02-18 新增) -- ❌ **详细错误弹框** - 所有 API 方式均失败时,弹框逐一展示每种方式的中文失败原因(⭐ 2026-02-18 新增) - -#### Token 刷新管理 -- 🔁 **全量刷新** - 一键刷新所有账号 Token,实时进度展示,防止 90 天过期(⭐ 2026-01-23 新增) -- ⏰ **定时刷新配置** - 支持按天数或 Cron 表达式配置自动刷新,提供常用样例(⭐ 2026-01-25 新增) -- 🔔 **定时刷新开关** - 可随时启用或禁用定时刷新任务(⭐ 2026-01-25 新增) -- 📊 **刷新统计优化** - 实时显示当前失败状态的邮箱数量,准确反映账号健康状态(⭐ 2026-01-25 新增) -- 📜 **刷新历史管理** - 展示近半年完整刷新历史,自动清理过期记录(⭐ 2026-01-25 新增) -- ❌ **失败邮箱标识** - 刷新失败的邮箱加粗标红显示,一目了然(⭐ 2026-01-25 新增) -- 📝 **备注展示** - 邮箱列表中显示账号备注信息,便于识别和管理(⭐ 2026-01-25 新增) - -#### 安全特性(⭐ 2026-01-26 新增) -- 🛡️ **XSS 防护** - 多层防护机制,防止跨站脚本攻击 - - 邮件内容使用 DOMPurify 净化,移除恶意脚本 - - iframe 沙箱隔离,禁止脚本执行 - - 用户输入自动转义,防止存储型 XSS -- 🔒 **CSRF 防护** - 使用 Flask-WTF 实现 CSRF Token 验证 - - 所有状态变更操作需要 CSRF Token - - 自动化 Token 管理,对用户透明 - - 优雅降级,未安装时仍可正常使用 -- 🔐 **敏感数据加密** - 使用 Fernet 对称加密保护敏感信息 - - Refresh Token 加密存储 - - 密码哈希存储(bcrypt) - - 基于 SECRET_KEY 的加密密钥派生 -- 🚦 **登录速率限制** - 防止暴力破解攻击 - - 5 次失败后锁定 15 分钟 - - 基于 IP 地址的速率限制 - - 自动解锁机制 -- 📋 **审计日志** - 记录所有敏感操作 - - 导出操作审计 - - 记录操作时间、IP 地址和详细信息 - - 便于安全追溯和合规审计 -- 🔑 **二次验证** - 导出功能需要密码确认 - - 一次性验证 Token - - 防止未授权导出 - - 保护敏感凭据安全 - -### 界面布局 -Web 应用采用四栏式布局设计: -1. **分组面板** - 显示所有邮箱分组,点击切换 -2. **邮箱面板** - 显示当前分组下的邮箱账号列表 -3. **邮件列表** - 显示选中邮箱的邮件,支持切换文件夹和滚动加载 -4. **邮件详情** - 显示选中邮件的完整内容(支持 HTML 渲染) +- 🔐 **多账号管理** - 支持批量导入和管理多个 Outlook 邮箱账号 +- 📁 **分组管理** - 自定义分组,支持分组级别代理设置 +- 📧 **多种读取方式** - Graph API / IMAP(新) / IMAP(旧) 自动回退 +- 🔄 **Token 自动刷新** - 支持定时刷新,防止 90 天过期 +- 🏷️ **标签系统** - 为邮箱打标签,支持筛选和批量操作 +- 🔍 **全局搜索** - 快速搜索邮箱和邮件 +- 🌐 **代理支持** - 分组级别 HTTP/SOCKS5 代理配置 +- 🔒 **安全加密** - 敏感数据加密存储,CSRF 防护,登录速率限制 +- 🎨 **现代化 UI** - 四栏式界面,响应式设计 +- 🐳 **Docker 部署** - 一键部署,开箱即用 ## 📸 界面预览 -本项目提供现代化的四栏式 Web 界面: -- 分组面板 - 管理邮箱分组 -- 邮箱面板 - 显示账号列表 -- 邮件列表 - 浏览邮件 -- 邮件详情 - 查看完整内容 +> 四栏式布局:分组面板 | 邮箱列表 | 邮件列表 | 邮件详情 +``` +┌─────────────┬──────────────┬──────────────┬──────────────┐ +│ 分组管理 │ 邮箱账号 │ 邮件列表 │ 邮件详情 │ +│ │ │ │ │ +│ 📁 默认分组 │ 📧 账号1 │ ✉️ 邮件1 │ 📄 邮件内容 │ +│ 📁 工作邮箱 │ 📧 账号2 │ ✉️ 邮件2 │ │ +│ 📁 个人邮箱 │ 📧 账号3 │ ✉️ 邮件3 │ │ +│ │ │ │ │ +└─────────────┴──────────────┴──────────────┴──────────────┘ +``` -## 📦 快速开始 +## 🚀 快速开始 -### 方式一:使用 Docker(推荐) - -直接使用 GitHub Actions 自动构建并推送到 Docker Hub 的镜像,无需本地构建: +### 使用 Docker(推荐) ```bash -# 拉取最新镜像 +# 拉取镜像 docker pull guangshanshui/outlook-email-plus:latest # 运行容器 docker run -d \ - --name outlook-mail-reader \ + --name outlook-email-plus \ -p 5000:5000 \ -v $(pwd)/data:/app/data \ -e LOGIN_PASSWORD=admin123 \ -e SECRET_KEY=your-secret-key-here \ guangshanshui/outlook-email-plus:latest -# 查看日志 -docker logs -f outlook-mail-reader - -# 停止容器 -docker stop outlook-mail-reader -docker rm outlook-mail-reader +# 访问应用 +# 浏览器打开 http://localhost:5000 ``` -**首次启动会自动:** -- 创建数据目录 -- 初始化数据库 -- 创建默认分组和临时邮箱分组 -- 设置默认密码(admin123) +### 使用 Docker Compose -### 方式二:使用 Python 直接运行 +```yaml +version: '3.8' + +services: + outlook-email-plus: + image: guangshanshui/outlook-email-plus:latest + container_name: outlook-email-plus + ports: + - "5000:5000" + volumes: + - ./data:/app/data + environment: + - LOGIN_PASSWORD=admin123 + - SECRET_KEY=your-secret-key-here + - FLASK_ENV=production + restart: unless-stopped +``` + +```bash +docker-compose up -d +``` + +### 本地运行 ```bash # 克隆仓库 @@ -134,188 +89,112 @@ cd outlookEmailPlus # 安装依赖 pip install -r requirements.txt -# 设置环境变量(可选) +# 设置环境变量 export LOGIN_PASSWORD=admin123 export SECRET_KEY=your-secret-key-here -export PORT=5000 # 运行应用 python web_outlook_app.py ``` -访问 `http://localhost:5000` 即可使用。 - -### 使用 Docker Compose - -修改 `docker-compose.yml` 使用预构建镜像: - -```yaml -version: '3.8' - -services: - outlook-mail-reader: - image: guangshanshui/outlook-email-plus:latest - container_name: outlook-mail-reader - ports: - - "5000:5000" - volumes: - - ./data:/app/data - environment: - - LOGIN_PASSWORD=admin123 - - SECRET_KEY=your-secret-key-here - - FLASK_ENV=production - - SCHEDULER_AUTOSTART=true - restart: unless-stopped -``` - -然后启动服务: - -```bash -# 启动服务 -docker-compose up -d - -# 查看日志 -docker-compose logs -f - -# 停止服务 -docker-compose down -``` - -## 🔧 配置说明 +## ⚙️ 配置说明 ### 环境变量 -在 `docker-compose.yml` 中可以配置以下环境变量: - | 变量名 | 说明 | 默认值 | |--------|------|--------| -| `SECRET_KEY` | Session 密钥(**必须设置**) | 无默认值,必须提供,请勿随意修改,数据库会基于这个加密,如果要改请先导出邮箱账号,改之后再重新导入账号 | +| `SECRET_KEY` | Session 密钥(必须设置) | 无 | | `LOGIN_PASSWORD` | 登录密码 | `admin123` | -| `FLASK_ENV` | 运行环境 | `production` | -| `SCHEDULER_AUTOSTART` | 是否自动启动后台调度器(定时刷新/心跳) | `true` | | `PORT` | 应用端口 | `5000` | | `HOST` | 监听地址 | `0.0.0.0` | +| `FLASK_ENV` | 运行环境 | `production` | | `DATABASE_PATH` | 数据库路径 | `data/outlook_accounts.db` | -| `GPTMAIL_BASE_URL` | GPTMail API 地址 | `https://mail.chatgpt.org.uk` | -| `GPTMAIL_API_KEY` | GPTMail API Key | `gpt-test` | -| `OAUTH_CLIENT_ID` | OAuth 客户端 ID | `建议使用自己的,如果实在搞不到不填的话会使用默认的` | -| `OAUTH_REDIRECT_URI` | OAuth 重定向 URI | `建议使用自己的,如果实在搞不到不填的话会使用默认的` | **生成 SECRET_KEY:** ```bash -# 使用 Python 生成随机密钥 python -c 'import secrets; print(secrets.token_hex(32))' ``` -## ✅ 运行验证(定时刷新/健康状态) +## 📖 使用说明 -- 定时刷新是否真实运行、未来触发时间预览、锁/运行中状态、最近一次定时刷新统计等:见 `docs/RUN/运行与定时刷新验证指南.md` -- 发布前回归清单与 P0 验收对照:见 `docs/QA/回归验证清单.md`、`docs/QA/P0验收对照表.md` -- 性能与容量基线(1,000 账号):见 `docs/PERF/性能与容量基线.md` +### 1. 获取 OAuth2 凭证 -## 🧪 自动化测试 +访问 [Azure Portal](https://portal.azure.com/) 注册应用: -```bash -pip install -r requirements.txt -python -m unittest discover -s tests -v +1. 进入「应用注册」→「新注册」 +2. 填写应用名称,选择「任何组织目录中的账户和个人 Microsoft 账户」 +3. 重定向 URI 选择「公共客户端/本机」,填写 `http://localhost:8080` +4. 复制「应用程序(客户端) ID」 +5. 使用本工具内置的 OAuth2 助手获取 Refresh Token + +### 2. 导入邮箱账号 + +格式:`邮箱----密码----client_id----refresh_token` + +``` +user@outlook.com----password123----24d9a0ed-xxxx----0.AXEA... ``` -## 🗂️ 项目结构(模块化) +支持批量导入,每行一个账号。 -- 入口(部署不变):`web_outlook_app:app`(`web_outlook_app.py`) -- 后端(Flask):`outlook_web/` - - `outlook_web/app.py`:`create_app()` 装配入口(DB/CSRF/错误处理/Blueprint 注册/调度器启动控制) - - `outlook_web/routes/`:按领域拆分的 Blueprint(URL 不变) - - `outlook_web/repositories/`:SQL 访问与数据映射集中 - - `outlook_web/services/`:业务编排(Graph/IMAP/GPTMail/刷新/删除回退等) - - `outlook_web/security/`:鉴权/加密/CSRF - - `outlook_web/errors.py`:统一错误结构/trace_id/默认脱敏 - - `outlook_web/legacy.py`:迁移期 view/兼容层(逐步瘦身,外部行为不变优先) -- 前端(零构建): - - `templates/index.html`:页面骨架(CSS/JS 外链) - - `static/css/main.css` - - `static/js/main.js` + `static/js/features/*.js` -- 测试:`tests/`(推荐命令:`python -m unittest discover -s tests -v`) +### 3. 查看邮件 -更详细的开发者说明见:`docs/DEV/00002-前后端拆分-开发者指南.md`。 +1. 选择分组和邮箱 +2. 点击「获取邮件」 +3. 切换文件夹(收件箱/垃圾邮件/已删除) +4. 点击邮件查看详情 -### 如何新增/调整一个 API(推荐流程) +### 4. Token 刷新管理 -1. 在 `outlook_web/repositories/` 增加/调整 SQL(必要时) -2. 在 `outlook_web/services/` 编排业务逻辑(尽量保持纯业务,不直接依赖 Flask request) -3. 在 `outlook_web/legacy.py` 增加一个薄的 view 函数(迁移期),仅做鉴权/参数解析/调用 service -4. 在 `outlook_web/routes/.py` 绑定 URL(Blueprint),并确保 URL/方法/响应结构保持契约一致 -5. 补齐/更新单测:优先门禁 `tests/test_smoke_contract.py`,再补充对应边界/回归用例 +- **全量刷新** - 一键刷新所有账号 Token +- **定时刷新** - 支持按天数或 Cron 表达式配置 +- **失败重试** - 查看失败原因并重试 -### 数据持久化 +建议每 30 天刷新一次,防止 Token 过期。 -数据库文件存储在 `./data` 目录中,通过 Docker Volume 挂载实现持久化。 +## 🔐 安全特性 -数据库包含以下表: -- `settings` - 系统设置(登录密码、API Key 等) -- `groups` - 邮箱分组 -- `accounts` - Outlook 邮箱账号 -- `account_refresh_logs` - 账号刷新记录(⭐ 2026-01-23 新增) -- `temp_emails` - 临时邮箱 -- `temp_email_messages` - 临时邮箱的邮件 +- ✅ 敏感数据加密存储(Fernet + bcrypt) +- ✅ CSRF 防护(Flask-WTF) +- ✅ XSS 防护(DOMPurify + iframe 沙箱) +- ✅ 登录速率限制(5 次失败锁定 15 分钟) +- ✅ 导出功能二次验证 +- ✅ 审计日志记录 -### 端口映射 +## 🛠️ 技术栈 -默认映射 5000 端口,可以在 `docker-compose.yml` 中修改: +**后端:** +- Flask 3.0+ - Web 框架 +- SQLite 3 - 数据库 +- Microsoft Graph API - Outlook 邮件 API +- APScheduler - 定时任务 -```yaml -ports: - - "8080:5000" # 将容器的 5000 端口映射到主机的 8080 端口 +**前端:** +- 原生 JavaScript - 无框架依赖 +- CSS3 - 现代化样式 +- DOMPurify - XSS 防护 + +## 📦 项目结构 + +``` +outlookEmailPlus/ +├── outlook_web/ # 后端源代码 +│ ├── app.py # Flask 应用入口 +│ ├── routes/ # 路由模块 +│ ├── services/ # 业务逻辑 +│ ├── repositories/ # 数据访问 +│ └── security/ # 安全模块 +├── static/ # 前端资源 +│ ├── css/ +│ └── js/ +├── templates/ # HTML 模板 +├── web_outlook_app.py # 应用入口 +├── requirements.txt # Python 依赖 +├── Dockerfile # Docker 构建 +└── README.md ``` -## 🚀 Docker 部署 - -### 使用 Docker Compose(推荐) - -修改 `docker-compose.yml` 使用预构建镜像: - -```yaml -version: '3.8' - -services: - outlook-mail-reader: - image: guangshanshui/outlook-email-plus:latest - container_name: outlook-mail-reader - ports: - - "5000:5000" - volumes: - - ./data:/app/data - environment: - - LOGIN_PASSWORD=admin123 - - SECRET_KEY=your-secret-key-here - - FLASK_ENV=production - - GPTMAIL_API_KEY=your-api-key - restart: unless-stopped -``` - -然后启动服务: - -```bash -# 启动服务 -docker-compose up -d - -# 查看日志 -docker-compose logs -f - -# 停止服务 -docker-compose down -``` - -### 镜像说明 - -项目使用 GitHub Actions 自动构建并推送 Docker 镜像到 Docker Hub:`guangshanshui/outlook-email-plus:latest`。 - -#### 可用镜像标签 - -- `guangshanshui/outlook-email-plus:latest` - 默认分支最新构建(推荐) -- `guangshanshui/outlook-email-plus:main` - main 分支最新版本(如默认分支为 main) - -#### 更新镜像 +## 🔄 更新应用 ```bash # 拉取最新镜像 @@ -326,1256 +205,32 @@ docker-compose down docker-compose up -d ``` -#### 自己构建镜像(可选) - -如果需要修改代码或自定义构建: - -```bash -# 构建镜像 -docker build -t outlook-mail-reader . - -# 运行自己构建的镜像 -docker run -d \ - --name outlook-mail-reader \ - -p 5000:5000 \ - -v $(pwd)/data:/app/data \ - -e LOGIN_PASSWORD=admin123 \ - outlook-mail-reader -``` - -## 📖 使用说明 - -### 1. 获取 OAuth2 凭证 - -要使用本工具,您需要获取以下 OAuth2 凭证: - -1. **Client ID** - Microsoft Azure 应用注册的客户端 ID -2. **Refresh Token** - OAuth2 刷新令牌 - -#### 步骤 1:注册 Azure 应用 - -访问 [Azure Portal](https://portal.azure.com/),进入「应用注册」创建新应用。 - -#### 步骤 2:创建新应用 - -点击「新注册」,填写应用信息: - -- **名称**:自定义应用名称 -- **支持的账户类型**:选择「任何组织目录中的账户和个人 Microsoft 账户」 -- **重定向 URI**:选择「公共客户端/本机」,填写 `http://localhost:8080` - -#### 步骤 3:获取应用程序 ID - -创建完成后,复制「应用程序(客户端) ID」。 - -#### 步骤 4:配置 API 权限 - -在「API 权限」中添加以下权限: -- `offline_access` - 获取刷新令牌 -- `Mail.Read` - 读取邮件 -- `Mail.ReadWrite` - 读写邮件 -- `User.Read` - 读取用户信息 -- `IMAP.AccessAsUser.All` - IMAP 访问 - -#### 步骤 5:获取 Refresh Token - -使用本工具内置的 OAuth2 助手获取 Refresh Token: - -1. 在 Web 界面点击「获取 Token」按钮 -2. 点击「生成授权链接」 -3. 复制链接到浏览器打开,完成授权 -4. 复制授权后的完整 URL(处于安全考虑,我没有统一建设授权回调服务,所有授权都在自己部署的服务内完成,不会外泄,所以重定向URI为http://localhost:8080,这个链接肯定是打不开的,所以要复制过来在部署的服务走后半段的换取Refresh Token) -5. 粘贴到「授权后的 URL」输入框 -6. 点击「换取 Token」按钮 -7. 复制获得的 Refresh Token - -### 2. 导入邮箱账号 - -在 Web 界面中,点击「导入邮箱」按钮,按以下格式输入账号信息: - -``` -邮箱----密码----client_id----refresh_token -``` - -示例: -``` -user@outlook.com----password123----24d9a0ed-8787-4584-883c-2fd79308940a----0.AXEA... -``` - -支持批量导入,每行一个账号。**注意:导入邮箱时不能选择临时邮箱分组。** - -### 3. 查看邮件 - -1. 从左侧选择分组 -2. 选择邮箱账号 -3. 点击「获取邮件」按钮 -4. 切换「收件箱」、「垃圾邮件」或「已删除」标签查看不同文件夹的邮件 -5. 滚动到邮件列表底部自动加载下一页(每页20封) -6. 点击邮件查看详情(支持 HTML 渲染) -7. 点击「🔍 全屏查看」按钮,在全屏模式下查看完整邮件内容 - -**全屏查看功能:** -- 支持滚动查看长邮件的完整内容 -- 按 ESC 键或点击背景区域关闭全屏模式 -- 自动调整内容高度,确保邮件完整显示 -- 支持 HTML 邮件和纯文本邮件 - -**邮件缓存功能(⭐ 2026-01-29 新增):** -- 切换邮箱或文件夹时,如果已有缓存数据则立即展示,无需等待加载 -- 点击「获取邮件」按钮会清空当前邮件列表缓存并获取最新数据 -- 下拉加载更多邮件时,新数据会追加到现有缓存中 -- 刷新浏览器页面会清空所有缓存 - -**信任邮件模式(⭐ 2026-01-29 新增):** -- 在邮件详情工具栏中,「🔍 全屏查看」按钮旁边有「信任此邮件」复选框 -- 勾选后会弹出安全警告,确认后将显示邮件的原始内容(绕过 HTML 安全过滤) -- 适用于查看被过滤掉样式或脚本的复杂邮件 -- ⚠️ **安全警告**:信任模式会显示未经过滤的原始内容,可能包含恶意脚本,请仅对可信邮件使用 -- 切换到其他邮件时,信任模式会自动关闭 - -**注意:** 切换文件夹标签后,如果有缓存会直接显示,否则需要点击「获取邮件」按钮加载。 - -### 4. 临时邮箱功能 - -系统集成了 GPTMail API 提供临时邮箱服务: - -1. **生成临时邮箱** - 点击「临时邮箱」分组,然后点击「生成临时邮箱」按钮 -2. **查看邮件** - 选择临时邮箱后点击「获取邮件」按钮 -3. **刷新邮件** - 点击「刷新」按钮获取最新邮件 -4. **清空邮件** - 点击「清空」按钮,清空该邮箱的所有邮件 -5. **删除邮箱** - 点击「删除」按钮,删除邮箱及其所有邮件 - -临时邮箱数据会存储在本地数据库中,方便后续查看。 - -### 5. 分组管理 - -- **创建分组** - 点击分组面板的「+」按钮 -- **编辑分组** - 点击分组旁的「编辑」按钮,可修改名称、描述和颜色 -- **删除分组** - 点击分组旁的「删除」按钮(默认分组不可删除) -- **导出分组** - 点击分组旁的「导出」按钮,导出该分组下的所有邮箱账号 - -### 6. 标签管理(⭐ 2026-01-31 新增) - -标签功能允许您为邮箱账号添加自定义标签,方便分类和筛选。 - -#### 创建和管理标签 -1. 点击邮箱面板标题栏的「🏷️」按钮,打开标签管理弹窗 -2. 输入标签名称,选择标签颜色,点击「创建」 -3. 已有标签会显示在列表中,可点击右侧的「删除」按钮移除 - -#### 给邮箱打标签 -1. 在邮箱列表中,勾选一个或多个邮箱账号(每个邮箱左侧有复选框) -2. 底部会出现批量操作栏,显示「已选 N 项」 -3. 点击「打标签」按钮,选择要添加的标签,确认即可 -4. 标签会以彩色徽章形式显示在邮箱备注下方 - -#### 去除邮箱标签 -1. 勾选已打标签的邮箱账号 -2. 点击底部操作栏的「去标签」按钮 -3. 选择要移除的标签,确认即可 - -#### 按标签筛选邮箱 -1. 在排序按钮下方,有一行标签筛选区(仅在有标签时显示) -2. 勾选一个或多个标签复选框 -3. 邮箱列表会自动过滤,只显示包含所选标签的账号 -4. 取消所有勾选可恢复显示全部账号 - -#### 搜索标签 -- 在全局搜索框中输入标签名称,可搜索到带有该标签的邮箱账号 - -### 7. 批量移动分组(⭐ 2026-02-08 新增) - -支持批量选择多个邮箱账号,一次性移动到指定分组。 - -#### 使用步骤 -1. 在邮箱列表中,勾选需要移动的邮箱账号(每个邮箱左侧有复选框) -2. 底部会出现批量操作栏,显示「已选 N 项」 -3. 点击「移动分组」按钮 -4. 在弹出的对话框中选择目标分组 -5. 点击「确认移动」,账号将被移动到目标分组 - -> [!NOTE] -> **系统分组限制**:不能将邮箱移动到「临时邮箱」等系统分组。 - -### 8. 邮件删除(⭐ 2026-01-31 新增) - -支持单封或批量永久删除邮件。 - -> [!WARNING] -> **关于删除权限**:如果邮箱账号是从第三方购买的,大概率没有开启 `Mail.ReadWrite` 删除 API 权限,删除操作会失败。只有使用自己注册的 Azure 应用授权的账号,删除功能才能正常使用。 - -#### 单封删除 -1. 在邮件列表中点击打开一封邮件 -2. 在邮件详情工具栏右侧点击「🗑️ 删除」按钮 -3. 确认删除提示,邮件将被永久删除 - -#### 批量删除 -1. 在邮件列表中,勾选需要删除的邮件(每封邮件左侧有复选框) -2. 底部会出现批量操作栏,显示「已选 N 项」 -3. 点击「🗑️ 删除」按钮 -4. 确认删除提示,选中的邮件将被永久删除 - -> [!CAUTION] -> **永久删除**:删除操作不可恢复!邮件将被直接永久删除,不会移动到"已删除"文件夹。 - -### 📡 API 调用优先级与代理说明(⭐ 2026-02-18 新增) - -#### API 调用优先级 - -本工具在获取邮件、刷新 Token、删除邮件时,会按以下优先级自动尝试并回退: - -| 优先级 | 方式 | 说明 | -|--------|------|------| -| 1️⃣ | **Graph API** | 推荐方式,功能最完整 | -| 2️⃣ | **IMAP(新服务器)** | `outlook.live.com`,Graph 失败后回退 | -| 3️⃣ | **IMAP(旧服务器)** | `outlook.office365.com`,最后尝试 | - -> [!NOTE] -> 如果 Graph API 失败原因是**代理连接错误**(ProxyError),则不会继续回退 IMAP,因为代理问题与 API 方式无关。 - -#### 如何判断当前使用的是 Graph API - -- 获取邮件后,邮件列表上方会显示方法标签:`Graph API`、`IMAP (New)` 或 `IMAP (Old)` -- **通过本项目 OAuth2 助手授权的账号,都是使用 Graph API** -- 从第三方购买的账号,取决于其提供的 client_id 和 refresh_token 的权限配置 - -#### 分组代理支持 - -每个分组可配置 HTTP 或 SOCKS5 代理,分组下所有邮箱在以下操作时会走该代理: - -- ✅ 获取邮件(Graph API) -- ✅ 查看邮件详情(Graph API) -- ✅ 刷新 Token(包括手动刷新、全量刷新、定时刷新) -- ✅ 删除邮件(Graph API) - -**代理格式示例:** -``` -http://127.0.0.1:7890 -socks5://127.0.0.1:7891 -socks5://user:pass@proxy.example.com:1080 -``` - -> [!IMPORTANT] -> **仅 Graph API 请求支持走代理**,IMAP 连接目前不支持代理。如果你的账号需要通过代理访问,请确保该账号使用的是 Graph API 方式(通过本项目授权的账号默认使用 Graph API)。 - -> [!WARNING] -> 使用 SOCKS5 代理需要安装 `pysocks` 依赖(`pip install pysocks` 或 `pip install requests[socks]`),Docker 镜像已内置。 - -#### IMAP 删除邮件的限制 - -> [!CAUTION] -> **通过 IMAP 方式获取的邮件目前不支持删除操作。** 这是因为 Graph API 返回的邮件 ID 与 IMAP 的 UID 格式不兼容,无法互相通用。如果删除操作报错,请确认: -> 1. 你的账号是否通过本项目 OAuth2 助手授权(授权的账号使用 Graph API,支持删除) -> 2. 你的 client_id 是否拥有 `Mail.ReadWrite` 权限(购买的号大概率没有此权限) - -### 9. 系统设置 - -点击导航栏的「⚙️ 设置」按钮,可以修改以下配置: - -1. **登录密码** - 修改 Web 界面的登录密码(至少4位) -2. **GPTMail API Key** - 设置临时邮箱功能所需的 API Key -3. **定时刷新配置** - 配置 Token 自动刷新策略 - -#### 按天数定时刷新 -在设置中选择"按天数"策略,设置每隔 X 天自动刷新(1-90 天)。 - -#### 按 Cron 表达式定时刷新 -在设置中选择"Cron 表达式"策略,输入自定义的 Cron 表达式。 - -设置会保存在数据库中,重启应用后仍然有效。 - -### 10. Token 刷新管理(⭐ 2026-01-23 新增,2026-01-25 增强) - -为了防止 Refresh Token 因 90 天不使用而过期,系统提供了强大的 Token 刷新管理功能: - -#### 功能特性 -- **全量刷新** - 一键测试所有账号的 Token 有效性,支持强制刷新 -- **实时进度** - 显示当前处理的账号、成功/失败数量 -- **智能统计** - 显示总邮箱数、成功邮箱数、失败邮箱数 -- **失败重试** - 支持单个或批量重试失败的账号 -- **失败邮箱列表** - 查看当前失败状态的邮箱,显示错误原因和最后刷新时间 -- **刷新历史** - 展示近半年完整刷新历史,支持查看所有全量刷新记录 -- **定时刷新** - 支持按天数或 Cron 表达式配置自动刷新策略 -- **刷新开关** - 可随时启用或禁用定时刷新任务 -- **记录管理** - 自动清理超过半年的旧记录,保持数据库整洁 - -#### 使用方法 -1. 点击顶部导航栏的「全量刷新Token」按钮 -2. 确认刷新提示 -3. 实时查看刷新进度和统计 -4. 刷新完成后查看结果 -5. 如有失败,可点击「❌ 失败邮箱」查看详情并重试 -6. 点击「📋 刷新历史」查看近半年所有刷新记录 - -#### 定时刷新配置 -1. 点击「⚙️ 设置」按钮 -2. 在"Token 刷新设置"部分配置: - - **启用定时刷新**:勾选或取消勾选开关 - - **刷新策略**:选择"按天数"或"Cron 表达式" - - **按天数**:设置每隔 X 天自动刷新(1-90 天) - - **Cron 表达式**:输入 Cron 表达式或点击常用样例 -3. 点击"验证表达式"查看下次执行时间 -4. 保存设置,重启应用后生效 - -#### 常用 Cron 样例 -- 每天凌晨 2:00 - `0 2 * * *` -- 每周一凌晨 2:00 - `0 2 * * 1` -- 每月 1 号凌晨 2:00 - `0 2 1 * *` -- 每 3 天凌晨 2:00 - `0 2 */3 * *` -- 每 12 小时 - `0 */12 * * *` - -#### 建议 -- 建议启用定时刷新,设置为每 30 天自动刷新 -- 及时处理失败的账号,重新获取 Token -- 定期查看刷新历史,了解账号健康状态 -- 失败邮箱会在列表中加粗标红显示,便于识别 - - -## 🌐 生产环境部署 - -### 使用 Nginx + HTTPS - -**1. 安装 Nginx** -```bash -sudo apt install nginx certbot python3-certbot-nginx -y -``` - -**2. 配置 Nginx** `/etc/nginx/sites-available/outlook-mail-reader` -```nginx -server { - listen 80; - server_name your-domain.com; - - location / { - proxy_pass http://localhost:5000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # WebSocket 支持(如果需要) - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } -} -``` - -**3. 启用配置** -```bash -sudo ln -s /etc/nginx/sites-available/outlook-mail-reader /etc/nginx/sites-enabled/ -sudo nginx -t -sudo systemctl reload nginx -``` - -**4. 配置 HTTPS** -```bash -sudo certbot --nginx -d your-domain.com -``` - -### 使用 Caddy(更简单) - -```bash -# 安装 Caddy -sudo apt install caddy -y - -# 配置 /etc/caddy/Caddyfile -your-domain.com { - reverse_proxy localhost:5000 -} - -# 重载(自动 HTTPS) -sudo systemctl reload caddy -``` - -## 🔐 安全配置 - -### 1. 修改默认密码 - -**方式一:通过环境变量** - -在 `docker-compose.yml` 中: -```yaml -environment: - - LOGIN_PASSWORD=your_secure_password_here - - SECRET_KEY=your-random-secret-key-here -``` - -**方式二:通过 Web 界面** - -登录后点击「⚙️ 设置」按钮,在线修改登录密码。 - -### 2. 启用 CSRF 防护(推荐) - -CSRF 防护默认启用,如果未安装 flask-wtf,系统会优雅降级: - -```bash -# 安装 CSRF 防护依赖 -pip install flask-wtf>=1.2.0 - -# 或使用完整依赖安装 -pip install -r requirements.txt -``` - -**CSRF 防护特性:** -- 自动为所有状态变更操作添加 CSRF Token -- 防止跨站请求伪造攻击 -- 对用户完全透明,无需手动操作 -- 未安装时自动降级,不影响功能使用 - -### 3. 登录速率限制 - -系统内置登录速率限制,防止暴力破解: - -- **失败次数限制**:5 次失败后锁定 -- **锁定时长**:15 分钟 -- **基于 IP**:每个 IP 独立计数 -- **自动解锁**:锁定时间到期后自动解锁 - -### 4. 敏感数据加密 - -所有敏感数据都经过加密存储: - -**加密内容:** -- Refresh Token(Fernet 对称加密) -- 登录密码(bcrypt 哈希) -- 邮箱密码(Fernet 对称加密) - -**加密密钥:** -- 基于 SECRET_KEY 派生加密密钥 -- 使用 PBKDF2HMAC 密钥派生函数 -- 100,000 次迭代,SHA256 算法 - -**重要提示:** -- SECRET_KEY 必须设置且保持不变 -- 更改 SECRET_KEY 会导致无法解密已存储的数据 -- 如需更改,请先导出账号,更改后重新导入 - -### 5. 导出功能二次验证 - -导出功能需要密码确认,防止未授权导出: - -**保护机制:** -- 导出前需要输入登录密码 -- 一次性验证 Token,使用后立即失效 -- 所有导出操作记录审计日志 -- 记录操作时间、IP 地址和导出详情 - -**审计日志:** -```sql --- 查看导出审计日志 -SELECT * FROM audit_logs WHERE action = 'export' ORDER BY created_at DESC; -``` - -### 6. XSS 防护 - -多层 XSS 防护机制: - -**前端防护:** -- 用户输入自动转义(escapeHtml) -- 邮件内容使用 DOMPurify 净化 -- iframe 沙箱隔离(sandbox="allow-same-origin") - -**后端防护:** -- 输入净化函数(sanitize_input) -- HTML 特殊字符转义 -- 长度限制和控制字符过滤 - -**DOMPurify 配置:** -```javascript -DOMPurify.sanitize(content, { - ALLOWED_TAGS: ['a', 'b', 'i', 'u', 'strong', 'em', 'p', 'br', 'div', ...], - FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', ...], - FORBID_ATTR: ['onerror', 'onload', 'onclick', ...] -}); -``` - -### 7. 配置防火墙 - -```bash -# 允许 HTTP 和 HTTPS -sudo ufw allow 80/tcp -sudo ufw allow 443/tcp - -# 如果直接访问应用端口 -sudo ufw allow 5000/tcp - -# 启用防火墙 -sudo ufw enable -``` - -### 8. 限制访问来源(Nginx) - -```nginx -location / { - # 只允许内网访问 - allow 192.168.1.0/24; - deny all; - - proxy_pass http://localhost:5000; -} -``` - -### 9. 使用强密码 - -- 登录密码至少 8 位,包含大小写字母、数字和特殊字符 -- **SECRET_KEY 必须设置**,使用随机生成的长字符串(至少 32 字节) -- 生成方法:`python -c 'import secrets; print(secrets.token_hex(32))'` -- 定期更换密码 - -### 10. 数据备份 - -```bash -# 备份数据库 -cp data/outlook_accounts.db data/outlook_accounts.db.backup - -# 定期备份(crontab) -0 2 * * * cp /path/to/data/outlook_accounts.db /path/to/backup/outlook_accounts.db.$(date +\%Y\%m\%d) -``` - -### 安全最佳实践 - -1. **必须设置 SECRET_KEY**:使用随机生成的强密钥 -2. **启用 HTTPS**:生产环境使用 SSL/TLS 加密 -3. **定期更新**:及时更新到最新版本 -4. **监控日志**:定期查看审计日志和应用日志 -5. **限制访问**:使用防火墙和 Nginx 限制访问来源 -6. **备份数据**:定期备份数据库文件 -7. **强密码策略**:使用复杂密码并定期更换 -8. **安装 CSRF 防护**:`pip install flask-wtf` - -## 🛠️ 故障排查 - -### 容器无法启动 - -**检查步骤:** - -```bash -# 1. 查看容器状态 -docker ps -a - -# 2. 查看应用日志 -docker logs outlook-mail-reader - -# 3. 检查端口占用 -lsof -i :5000 - -# 4. 重新拉取镜像并重启 -docker pull guangshanshui/outlook-email-plus:latest -docker-compose down -docker-compose up -d -``` - -**正确的日志应该显示:** -``` -============================================================ -Outlook 邮件 Web 应用已初始化 -数据库文件: data/outlook_accounts.db -GPTMail API: https://mail.chatgpt.org.uk -============================================================ -``` - -### 数据库表不存在错误 - -**错误信息:** `sqlite3.OperationalError: no such table: settings` - -**原因:** 数据库未初始化或损坏 - -**解决方法:** - -```bash -# 方法 1:删除旧数据库,重新初始化 -docker-compose down -rm -rf data/outlook_accounts.db -docker-compose up -d - -# 方法 2:手动初始化数据库 -docker exec outlook-mail-reader python -c "from web_outlook_app import init_db; init_db()" -docker-compose restart - -# 方法 3:使用最新镜像 -docker pull guangshanshui/outlook-email-plus:latest -docker-compose down -docker-compose up -d -``` - -### 无法获取邮件 - -**可能原因:** -1. Refresh Token 过期或无效 -2. Client ID 错误 -3. API 权限不足 -4. 网络连接问题 - -**解决方法:** - -1. **重新获取 Refresh Token** - - 使用内置的 OAuth2 助手重新获取 - - 确保 Azure 应用配置正确 - -2. **检查 API 权限** - - 确保已添加所需的 API 权限 - - 确保已授予管理员同意(如果需要) - -3. **查看详细错误** - - 打开浏览器开发者工具(F12) - - 查看 Network 标签中的 API 响应 - -### 502 错误(Nginx) - -**原因:** 应用未正常启动或端口配置错误 - -**解决方法:** - -```bash -# 1. 检查容器状态 -docker ps - -# 2. 查看应用日志 -docker-compose logs - -# 3. 测试应用是否响应 -curl http://localhost:5000/login - -# 4. 检查 Nginx 配置 -sudo nginx -t - -# 5. 重启服务 -docker-compose restart -sudo systemctl reload nginx -``` - -### 临时邮箱功能不可用 - -**可能原因:** -1. GPTMail API Key 无效 -2. GPTMail 服务不可用 -3. 网络连接问题 - -**解决方法:** - -1. **更新 API Key** - - 在「⚙️ 设置」中更新 GPTMail API Key - - 或在环境变量中设置 `GPTMAIL_API_KEY` - -2. **检查服务状态** - - 访问 GPTMail 官网确认服务状态 - - 查看应用日志中的错误信息 - -### Session 过期问题 - -**现象:** 频繁需要重新登录 - -**解决方法:** - -1. **设置 SECRET_KEY 环境变量(必须)** - ```yaml - environment: - - SECRET_KEY=your-fixed-secret-key-here - ``` - 使用 `python -c 'import secrets; print(secrets.token_hex(32))'` 生成随机密钥 或者使用https://it.idev.dev/uuid-generator生成个UUID,或者自己填一个复杂的字符串 - -2. **检查 Session 配置** - - 默认 Session 有效期为 7 天 - - 重启应用不会导致 Session 失效(使用固定 SECRET_KEY) - -### 数据库锁定错误 - -**错误信息:** `sqlite3.OperationalError: database is locked` - -**原因:** 多个进程同时访问数据库 - -**解决方法:** - -```bash -# 1. 重启应用 -docker-compose restart - -# 2. 如果问题持续,检查是否有其他进程访问数据库 -lsof data/outlook_accounts.db - -# 3. 备份并重建数据库 -cp data/outlook_accounts.db data/outlook_accounts.db.backup -docker-compose down -docker-compose up -d -``` - -## 🔄 更新应用 - -### 更新到最新版本 - -```bash -# 拉取最新镜像 -docker pull guangshanshui/outlook-email-plus:latest - -# 重启服务 -docker-compose down -docker-compose up -d - -# 或使用 Docker 命令 -docker stop outlook-mail-reader -docker rm outlook-mail-reader -docker run -d \ - --name outlook-mail-reader \ - -p 5000:5000 \ - -v $(pwd)/data:/app/data \ - -e LOGIN_PASSWORD=admin123 \ - -e SECRET_KEY=your-secret-key-here \ - guangshanshui/outlook-email-plus:latest -``` - -### 查看版本信息 - -```bash -# 查看镜像信息 -docker images guangshanshui/outlook-email-plus - -# 查看容器日志 -docker logs outlook-mail-reader -``` - -## 🏗️ 技术架构 - -### 后端技术栈 - -- **Flask 3.0+** - Web 框架 -- **SQLite 3** - 数据库 -- **Requests** - HTTP 客户端 -- **IMAP4_SSL** - IMAP 协议支持 -- **Microsoft Graph API** - Outlook 邮件 API - -### 前端技术栈 - -- **原生 JavaScript** - 无框架依赖 -- **CSS3** - 现代化样式 -- **Fetch API** - 异步请求 - -### 数据库设计 - -#### settings 表 -存储系统设置: -- `key` - 设置键(主键) -- `value` - 设置值 -- `updated_at` - 更新时间 - -#### groups 表 -存储邮箱分组: -- `id` - 分组 ID(主键) -- `name` - 分组名称(唯一) -- `description` - 分组描述 -- `color` - 分组颜色 -- `is_system` - 是否系统分组 -- `created_at` - 创建时间 - -#### accounts 表 -存储 Outlook 邮箱账号: -- `id` - 账号 ID(主键) -- `email` - 邮箱地址(唯一) -- `password` - 邮箱密码 -- `client_id` - OAuth 客户端 ID -- `refresh_token` - OAuth 刷新令牌 -- `group_id` - 所属分组 ID(外键) -- `remark` - 备注(⭐ 2026-01-25 增强:在列表中展示) -- `status` - 账号状态 -- `last_refresh_at` - 最后刷新时间(⭐ 2026-01-23 新增) -- `created_at` - 创建时间 -- `updated_at` - 更新时间 - -#### account_refresh_logs 表(⭐ 2026-01-23 新增) -存储账号刷新记录: -- `id` - 记录 ID(主键) -- `account_id` - 账号 ID(外键) -- `account_email` - 账号邮箱 -- `refresh_type` - 刷新类型(manual/retry/auto) -- `status` - 刷新状态(success/failed) -- `error_message` - 错误信息 -- `created_at` - 创建时间 - -#### temp_emails 表 -存储临时邮箱: -- `id` - 邮箱 ID(主键) -- `email` - 邮箱地址(唯一) -- `status` - 邮箱状态 -- `created_at` - 创建时间 -- `updated_at` - 更新时间 - -#### temp_email_messages 表 -存储临时邮箱的邮件: -- `id` - 邮件 ID(主键) -- `message_id` - 邮件唯一标识(唯一) -- `email_address` - 邮箱地址(外键) -- `from_address` - 发件人 -- `subject` - 邮件主题 -- `content` - 纯文本内容 -- `html_content` - HTML 内容 -- `has_html` - 是否包含 HTML -- `timestamp` - 时间戳 -- `created_at` - 创建时间 - -### API 端点 - -#### 认证相关 -- `GET /login` - 登录页面 -- `POST /login` - 登录验证 -- `GET /logout` - 退出登录 - -#### 分组管理 -- `GET /api/groups` - 获取所有分组 -- `GET /api/groups/` - 获取单个分组 -- `POST /api/groups` - 创建分组 -- `PUT /api/groups/` - 更新分组 -- `DELETE /api/groups/` - 删除分组 -- `GET /api/groups//export` - 导出分组账号 - -#### 账号管理 -- `GET /api/accounts` - 获取所有账号 -- `GET /api/accounts/` - 获取单个账号 -- `POST /api/accounts` - 添加账号 -- `PUT /api/accounts/` - 更新账号 -- `DELETE /api/accounts/` - 删除账号 -- `GET /api/accounts/export` - 导出所有账号 -- `POST /api/accounts/export-selected` - 导出选中分组账号 - -#### Token 刷新管理(⭐ 2026-01-23 新增) -- `GET /api/accounts/refresh-all` - 全量刷新所有账号(流式响应) -- `POST /api/accounts//refresh` - 刷新单个账号 -- `POST /api/accounts/refresh-failed` - 重试所有失败的账号 -- `POST /api/accounts//retry-refresh` - 重试单个账号 -- `GET /api/accounts/refresh-logs` - 获取刷新历史 -- `GET /api/accounts//refresh-logs` - 获取单个账号的刷新历史 -- `GET /api/accounts/refresh-logs/failed` - 获取失败的刷新记录 -- `GET /api/accounts/refresh-stats` - 获取刷新统计信息 - -#### 邮件操作 -- `GET /api/emails/` - 获取邮件列表 -- `GET /api/email//` - 获取邮件详情 - -#### 临时邮箱 -- `GET /api/temp-emails` - 获取所有临时邮箱 -- `POST /api/temp-emails/generate` - 生成临时邮箱 -- `DELETE /api/temp-emails/` - 删除临时邮箱 -- `GET /api/temp-emails//messages` - 获取临时邮箱邮件 -- `GET /api/temp-emails//messages/` - 获取临时邮件详情 -- `DELETE /api/temp-emails//messages/` - 删除临时邮件 -- `DELETE /api/temp-emails//clear` - 清空临时邮箱 -- `POST /api/temp-emails//refresh` - 刷新临时邮箱 - -#### OAuth2 助手 -- `GET /api/oauth/auth-url` - 生成授权 URL -- `POST /api/oauth/exchange-token` - 换取 Refresh Token - -#### 系统设置 -- `GET /api/settings` - 获取所有设置 -- `PUT /api/settings` - 更新设置 - -### 特性说明 - -#### 1. 多种邮件读取方式 -- **Graph API**:推荐方式,功能最完整,支持所有文件夹 -- **IMAP(新版)**:使用 `outlook.live.com` 服务器 -- **IMAP(旧版)**:使用 `outlook.office365.com` 服务器 - -#### 2. 智能缓存机制 -- 邮箱列表缓存在前端,快速切换分组 -- 邮件列表不使用缓存,确保数据实时性 -- Session 持久化,重启不丢失登录状态 - -#### 3. 分页加载 -- 每页加载 20 封邮件 -- 滚动到底部自动加载下一页 -- 支持 `skip` 和 `top` 参数控制分页 - -#### 4. 安全特性 -- 密码加密存储(可选) -- Session 密钥配置 -- 登录验证装饰器 -- 敏感信息隐藏(API 响应中) - -#### 5. 临时邮箱集成 -- 集成 GPTMail API -- 本地数据库缓存 -- 支持自定义前缀和域名 -- 自动刷新邮件 - -## 📝 依赖说明 - -### Python 依赖 - -```txt -flask>=3.0.0 -flask-wtf>=1.2.0 # CSRF 防护(推荐安装) -werkzeug>=3.0.0 -requests>=2.25.0 -APScheduler>=3.10.0 # 定时任务 -croniter>=1.3.0 # Cron 表达式解析 -bcrypt>=4.0.0 # 密码哈希 -cryptography>=41.0.0 # 数据加密 -``` - -### 前端依赖 - -- **DOMPurify 3.0.8** - HTML 净化,防止 XSS 攻击(CDN 引入) -- **原生 JavaScript** - 无框架依赖 -- **CSS3** - 现代化样式 -- **Fetch API** - 异步请求 - -### 系统要求 - -- Python 3.8+ -- SQLite 3 -- Docker(可选) -- 2GB+ 内存 -- 1GB+ 磁盘空间 - -### 可选依赖 - -- **flask-wtf** - CSRF 防护(强烈推荐) - ```bash - pip install flask-wtf>=1.2.0 - ``` - 未安装时系统会自动降级,但失去 CSRF 保护 - ## ❓ 常见问题 -### Q: 为什么无法获取邮件? -A: 请检查以下几点: -1. Refresh Token 是否有效(可能已过期) -2. Client ID 是否正确 -3. Azure 应用是否配置了正确的 API 权限 -4. 网络连接是否正常 -5. 尝试使用内置的 OAuth2 助手重新获取 Token +**Q: 无法获取邮件?** +A: 检查 Refresh Token 是否有效,Client ID 是否正确,API 权限是否配置。 -### Q: 如何获取 Refresh Token? -A: 使用本工具内置的 OAuth2 助手: -1. 点击导航栏的「获取 Token」按钮 -2. 点击「生成授权链接」 -3. 在浏览器中打开链接并完成授权 -4. 复制授权后的完整 URL -5. 粘贴到「授权后的 URL」输入框 -6. 点击「换取 Token」按钮 -7. 复制获得的 Refresh Token +**Q: 如何修改登录密码?** +A: 登录后点击「⚙️ 设置」在线修改,或通过环境变量 `LOGIN_PASSWORD` 设置。 -### Q: 临时邮箱功能如何使用? -A: 临时邮箱功能集成了 GPTMail API: -1. 点击「临时邮箱」分组 -2. 点击「生成临时邮箱」按钮 -3. 选择生成的邮箱,点击「获取邮件」查看邮件 -4. 临时邮箱数据会自动保存到本地数据库 +**Q: 数据存储在哪里?** +A: SQLite 数据库位于 `data/outlook_accounts.db`,建议定期备份。 -### Q: 如何修改登录密码? -A: 有两种方式: -1. 通过 Web 界面:登录后点击「⚙️ 设置」按钮,在线修改 -2. 通过环境变量:在 `docker-compose.yml` 中设置 `LOGIN_PASSWORD` +**Q: 支持哪些邮件文件夹?** +A: 收件箱、垃圾邮件、已删除邮件。 -### Q: 数据存储在哪里? -A: 所有数据存储在 SQLite 数据库中,位于 `data/outlook_accounts.db`。建议定期备份此文件。 +## 📄 许可证 -### Q: 支持哪些邮件文件夹? -A: 目前支持以下文件夹: -- 收件箱(Inbox) -- 垃圾邮件(Junk Email) -- 已删除邮件(Deleted Items) - -### Q: 如何批量导入邮箱? -A: 在「导入邮箱」对话框中,每行输入一个账号,格式为: -``` -邮箱----密码----client_id----refresh_token -``` -支持一次导入多个账号。 - -### Q: 邮件列表是否有缓存? -A: 邮件列表不使用缓存,每次点击「获取邮件」都会从服务器获取最新数据。这样可以确保邮件数据的实时性。 - -### Q: 如何导出邮箱账号? -A: 有三种导出方式: -1. 导出单个分组:点击分组旁的「导出」按钮 -2. 导出所有账号:点击导航栏的「导出全部」按钮 -3. 导出选中分组:选择多个分组后点击「导出选中」按钮 - -### Q: Docker 容器无法启动怎么办? -A: 请按以下步骤排查: -1. 查看容器日志:`docker logs outlook-mail-reader` -2. 检查端口是否被占用:`lsof -i :5000` -3. 确保数据目录有写入权限 -4. 尝试重新拉取最新镜像 - -## 📚 相关文档 - -- [Docker 官方文档](https://docs.docker.com/) -- [Docker Compose 文档](https://docs.docker.com/compose/) -- [Microsoft Graph API 文档](https://docs.microsoft.com/graph/) -- [Microsoft Identity Platform 文档](https://docs.microsoft.com/azure/active-directory/develop/) -- [Flask 官方文档](https://flask.palletsprojects.com/) -- [SQLite 官方文档](https://www.sqlite.org/docs.html) +MIT License - 详见 [LICENSE](LICENSE) 文件 ## 🤝 贡献 欢迎提交 Issue 和 Pull Request! -### 贡献指南 - -1. Fork 本仓库 -2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) -3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) -4. 推送到分支 (`git push origin feature/AmazingFeature`) -5. 开启 Pull Request - -### 开发环境设置 - -```bash -# 克隆仓库 -git clone https://github.com/ZeroPointSix/outlookEmailPlus.git -cd outlookEmailPlus - -# 创建虚拟环境 -python -m venv venv -source venv/bin/activate # Windows: venv\Scripts\activate - -# 安装依赖 -pip install -r requirements.txt - -# 运行应用 -python web_outlook_app.py -``` - -## 📄 许可证 - -MIT License - -Copyright (c) 2024 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -## 🙏 致谢 - -- [Microsoft Graph API](https://docs.microsoft.com/graph/) - 提供 Outlook 邮件 API -- [GPTMail](https://mail.chatgpt.org.uk) - 提供临时邮箱服务 -- [Flask](https://flask.palletsprojects.com/) - Web 框架 -- 所有贡献者和使用者 - ## 📞 联系方式 - GitHub Issues: [https://github.com/ZeroPointSix/outlookEmailPlus/issues](https://github.com/ZeroPointSix/outlookEmailPlus/issues) -- Email: 通过 GitHub Issues 联系 --- -## 📋 更新日志 - -### 2026-01-26 - 安全增强版本 - -#### 新增安全特性 -- 🛡️ **XSS 防护** - 多层防护机制,全面防止跨站脚本攻击 - - 邮件内容使用 DOMPurify 3.0.8 净化,移除恶意脚本 - - iframe 沙箱隔离(sandbox="allow-same-origin"),禁止脚本执行 - - 用户输入自动转义,防止存储型 XSS - - 后端输入净化函数,过滤控制字符和限制长度 - - 安全等级从 CVSS 8.5 降低至 CVSS 2.0 - -- 🔒 **CSRF 防护** - 使用 Flask-WTF 实现完整的 CSRF Token 验证 - - 所有状态变更操作需要 CSRF Token - - 自动化 Token 管理,对用户透明 - - 优雅降级,未安装 flask-wtf 时仍可正常使用 - - 登录和 Token 获取接口自动排除 CSRF 保护 - - 安全等级从 CVSS 7.1 降低至 CVSS 2.0 - -- 🔐 **敏感数据加密** - 使用 Fernet 对称加密保护敏感信息 - - Refresh Token 加密存储 - - 密码哈希存储(bcrypt) - - 基于 SECRET_KEY 的加密密钥派生(PBKDF2HMAC) - - 100,000 次迭代,SHA256 算法 - -- 🚦 **登录速率限制** - 防止暴力破解攻击 - - 5 次失败后锁定 15 分钟 - - 基于 IP 地址的速率限制 - - 自动解锁机制 - - 友好的错误提示 - -- 📋 **审计日志** - 记录所有敏感操作 - - 新增 audit_logs 表 - - 记录导出操作的时间、IP 地址和详细信息 - - 便于安全追溯和合规审计 - -- 🔑 **二次验证** - 导出功能需要密码确认 - - 导出前需要输入登录密码 - - 一次性验证 Token,使用后立即失效 - - 防止未授权导出敏感凭据 - - 安全等级从 CVSS 7.8 降低至 CVSS 3.0 - -#### 数据库变更 -- 新增 `audit_logs` 表 - 存储审计日志 - - action: 操作类型 - - resource_type: 资源类型 - - resource_id: 资源ID - - user_ip: 用户IP - - details: 详细信息 - - created_at: 操作时间 - -#### API 变更 -- 新增 `/api/export/verify` - 生成导出验证 Token -- 新增 `/api/csrf-token` - 获取 CSRF Token -- 所有导出接口增加二次验证机制 -- 所有状态变更接口增加 CSRF 保护 - -#### 依赖更新 -- 新增 `flask-wtf>=1.2.0` - CSRF 防护(可选) -- 新增 `html` 模块 - HTML 转义 - -#### 技术亮点 -- 条件导入 flask-wtf,未安装时优雅降级 -- 前端自动拦截 fetch 请求,添加 CSRF Token -- DOMPurify 配置白名单,只允许安全标签和属性 -- 审计日志异步记录,不影响主流程性能 - -#### 安全建议 -- **强烈建议安装 flask-wtf**:`pip install flask-wtf>=1.2.0` -- **必须设置 SECRET_KEY**:使用随机生成的强密钥 -- **启用 HTTPS**:生产环境使用 SSL/TLS 加密 -- **定期查看审计日志**:监控异常操作 -- **定期更新密码**:使用强密码并定期更换 - -#### 破坏性变更 -- 无破坏性变更,完全向后兼容 -- 未安装 flask-wtf 时自动禁用 CSRF 保护 -- 所有新特性都是增强型,不影响现有功能 - ---- - -### 2026-01-25 - 备注展示功能 - -#### 新增功能 -- 📝 **邮箱列表备注展示** - 在邮箱列表中直接显示账号备注信息 - - 备注显示在邮箱地址下方,使用 📝 图标标识 - - 超长备注自动截断,鼠标悬停显示完整内容 - - 仅当备注存在时显示,无备注账号不占用空间 - - 支持特殊字符正确转义,不破坏页面结构 - - 与现有功能完全兼容(搜索、排序、编辑等) - -#### 界面优化 -- 优化邮箱列表布局,备注信息与刷新时间对齐 -- 备注文本使用灰色显示(#666),字体 12px -- 超过容器宽度的备注显示省略号(...) - -#### 使用说明 -- 在编辑账号时可以添加或修改备注 -- 备注会自动显示在邮箱列表中 -- 鼠标悬停在备注上可查看完整内容 -- 备注信息便于识别和管理多个邮箱账号 - ---- - -### 2026-01-25 - Token 刷新管理增强 - -#### 新增功能 -- ⏰ **灵活的定时刷新配置** - 支持两种刷新策略 - - 按天数配置:每隔 X 天自动刷新(1-90 天可选) - - 按 Cron 表达式配置:支持更灵活的定时策略 - - 提供 5 个常用 Cron 样例,点击即可自动填充 - - 实时验证 Cron 表达式,显示下次执行时间 -- 🔔 **定时刷新开关** - 可随时启用或禁用定时刷新任务 - - 关闭后定时任务不会执行,但手动刷新仍可用 - - 设置保存后重启应用生效 -- 📊 **刷新统计优化** - 更准确的统计信息 - - 总邮箱数:显示活跃邮箱总数 - - 成功邮箱:当前刷新状态为成功的邮箱数量 - - 失败邮箱:当前刷新状态为失败的邮箱数量 - - 实时反映账号健康状态,而非单次刷新结果 -- 📜 **刷新历史管理优化** - - 展示近半年完整刷新历史(最多 1000 条) - - 全量刷新时自动清理超过半年的旧记录 - - 不再清空所有记录,保留历史数据便于追溯 -- ❌ **失败邮箱视觉优化** - - 移除"刷新失败"标签,改为邮箱地址加粗标红 - - 界面更简洁,失败状态更醒目 - - 保留"查看错误"按钮,方便查看详细错误信息 -- 📋 **失败列表改进** - - 移除成功列表,简化界面 - - 失败列表改为"当前失败状态的邮箱列表" - - 显示每个失败邮箱的最后刷新时间和错误信息 - -#### 数据库变更 -- 新增 `enable_scheduled_refresh` 配置 - 控制定时刷新开关 -- 新增 `refresh_cron` 配置 - 存储 Cron 表达式 -- 新增 `use_cron_schedule` 配置 - 选择刷新策略 - -#### API 变更 -- `/api/settings/validate-cron` - 新增 Cron 表达式验证 API -- `/api/accounts/refresh-stats` - 优化统计逻辑,只统计当前失败状态 -- `/api/accounts/refresh-logs` - 支持近半年历史记录查询 -- `/api/accounts/trigger-scheduled-refresh` - 支持 `force` 参数强制刷新 - -#### 技术亮点 -- 使用 APScheduler 的 CronTrigger 支持 Cron 表达式 -- 使用 croniter 库验证和解析 Cron 表达式 -- 优化刷新记录管理,避免数据冗余 -- 改进统计查询逻辑,准确反映账号状态 - -#### 使用建议 -- 建议启用定时刷新,设置为每 30 天自动刷新 -- 使用 Cron 表达式可实现更灵活的刷新策略 -- 定期查看失败邮箱列表,及时处理问题账号 -- 刷新历史保留半年,便于追溯和分析 - ---- - -### 2026-01-23 - Token 刷新管理功能 - -#### 新增功能 -- ✨ **全量刷新 Token** - 一键测试所有账号的 Refresh Token 有效性 -- 📊 **实时进度展示** - 使用 Server-Sent Events 实现流式响应,实时显示刷新进度 -- 📈 **刷新统计** - 显示本次刷新的总数、成功数、失败数和上次刷新时间 -- 🔁 **失败重试** - 支持单个账号重试和批量重试所有失败账号 -- ✅ **成功列表** - 查看所有刷新成功的账号记录 -- ❌ **失败列表** - 查看失败原因,支持单独重试 -- 🗄️ **记录管理** - 只保留最近一次全量刷新记录,避免数据冗余 -- 🕐 **时区优化** - 统一使用上海时区(UTC+8),确保时间显示准确 - -#### 数据库变更 -- 新增 `account_refresh_logs` 表 - 存储刷新记录 -- `accounts` 表新增 `last_refresh_at` 字段 - 记录最后刷新时间 - -#### API 变更 -- 新增 8 个刷新相关 API 端点 -- 使用流式响应(SSE)实现实时进度推送 - -#### 技术亮点 -- 使用 EventSource API 实现前端实时进度接收 -- 优化时区处理,避免 UTC 和本地时区的 8 小时偏差 -- 实时同步顶部统计数据和进度条数据 -- 改进邮件内容展示,支持长邮件完整显示 -- 🔍 **全屏查看邮件** - 新增全屏模式查看邮件功能,解决长邮件展示不全的问题 - - 支持滚动查看完整邮件内容 - - 自动调整 iframe 高度,确保内容完整显示 - - 支持 ESC 键和点击背景关闭 - - 优化 HTML 邮件和纯文本邮件的显示效果 - -#### 使用建议 -- 建议每月至少执行一次全量刷新,防止 Token 过期 -- 及时处理失败的账号,重新获取 Refresh Token -- 定期查看刷新历史,了解账号健康状态 - ---- - -## ⭐ Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=ZeroPointSix/outlookEmailPlus&type=Date)](https://star-history.com/#ZeroPointSix/outlookEmailPlus&Date) - ---- - -**⭐ 如果这个项目对你有帮助,请给个 Star 支持一下!你的 Star 是我持续更新的动力!** ⭐ +**⭐ 如果这个项目对你有帮助,请给个 Star 支持一下!** diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index ab90a23..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,136 +0,0 @@ -# Security Policy / 安全策略 - -## Supported Versions / 支持的版本 - -We release patches for security vulnerabilities for the following versions: / 我们为以下版本发布安全漏洞补丁: - -| Version / 版本 | Supported / 支持 | -| -------------- | ------------------------- | -| latest (main) | :white_check_mark: | -| < latest | :x: | - -## Reporting a Vulnerability / 报告漏洞 - -**Please do not report security vulnerabilities through public GitHub issues.** / **请勿通过公开的 GitHub issue 报告安全漏洞。** - -If you discover a security vulnerability, please send an email to: / 如果你发现安全漏洞,请发送邮件至: - -**Email / 邮箱:** 3052147989@qq.com - -Please include the following information in your report: / 请在报告中包含以下信息: - -- Type of vulnerability / 漏洞类型 -- Full paths of source file(s) related to the vulnerability / 与漏洞相关的源文件完整路径 -- Location of the affected source code (tag/branch/commit or direct URL) / 受影响源代码的位置(tag/分支/commit 或直接 URL) -- Step-by-step instructions to reproduce the issue / 重现问题的分步说明 -- Proof-of-concept or exploit code (if possible) / 概念验证或漏洞利用代码(如可能) -- Impact of the vulnerability / 漏洞的影响 - -## Response Timeline / 响应时间表 - -- **Initial Response / 初始响应:** We will acknowledge receipt of your vulnerability report within 48 hours. / 我们将在 48 小时内确认收到你的漏洞报告。 -- **Status Update / 状态更新:** We will provide a more detailed response within 7 days, indicating the next steps in handling your report. / 我们将在 7 天内提供更详细的响应,说明处理报告的后续步骤。 -- **Fix Timeline / 修复时间表:** We will work to fix confirmed vulnerabilities as quickly as possible, typically within 30 days. / 我们将尽快修复已确认的漏洞,通常在 30 天内。 - -## Disclosure Policy / 披露政策 - -- Please give us reasonable time to fix the vulnerability before public disclosure. / 请在公开披露前给我们合理的时间修复漏洞。 -- We will credit you in the security advisory unless you prefer to remain anonymous. / 我们将在安全公告中致谢你,除非你希望保持匿名。 -- We will coordinate with you on the disclosure timeline. / 我们将与你协调披露时间表。 - -## Security Best Practices / 安全最佳实践 - -When using this application, please follow these security best practices: / 使用此应用时,请遵循以下安全最佳实践: - -### For Users / 用户 - -1. **Strong Passwords / 强密码** - - Use a strong, unique password for the web interface / 为 Web 界面使用强且唯一的密码 - - Change the default password immediately / 立即更改默认密码 - - Use at least 12 characters with mixed case, numbers, and symbols / 使用至少 12 个字符,包含大小写、数字和符号 - -2. **SECRET_KEY Management / SECRET_KEY 管理** - - Always set a strong SECRET_KEY environment variable / 始终设置强 SECRET_KEY 环境变量 - - Never commit SECRET_KEY to version control / 切勿将 SECRET_KEY 提交到版本控制 - - Use a cryptographically secure random string / 使用加密安全的随机字符串 - - Generate with: `python -c 'import secrets; print(secrets.token_hex(32))'` - -3. **HTTPS / HTTPS** - - Always use HTTPS in production / 生产环境始终使用 HTTPS - - Use a reverse proxy (Nginx/Caddy) with SSL/TLS / 使用带 SSL/TLS 的反向代理(Nginx/Caddy) - - Enable HSTS (HTTP Strict Transport Security) / 启用 HSTS(HTTP 严格传输安全) - -4. **Access Control / 访问控制** - - Limit access to trusted networks / 限制对受信任网络的访问 - - Use firewall rules to restrict access / 使用防火墙规则限制访问 - - Consider using VPN for remote access / 考虑使用 VPN 进行远程访问 - -5. **Regular Updates / 定期更新** - - Keep the application updated to the latest version / 保持应用更新到最新版本 - - Monitor security advisories / 监控安全公告 - - Update dependencies regularly / 定期更新依赖 - -6. **Data Backup / 数据备份** - - Regularly backup the database file / 定期备份数据库文件 - - Store backups securely / 安全存储备份 - - Test backup restoration / 测试备份恢复 - -### For Developers / 开发者 - -1. **Input Validation / 输入验证** - - Always validate and sanitize user input / 始终验证和净化用户输入 - - Use parameterized queries to prevent SQL injection / 使用参数化查询防止 SQL 注入 - - Implement proper error handling / 实现适当的错误处理 - -2. **Authentication & Authorization / 认证与授权** - - Use bcrypt for password hashing / 使用 bcrypt 进行密码哈希 - - Implement rate limiting for login attempts / 为登录尝试实施速率限制 - - Use secure session management / 使用安全的会话管理 - -3. **Data Protection / 数据保护** - - Encrypt sensitive data at rest / 加密静态敏感数据 - - Use HTTPS for data in transit / 使用 HTTPS 传输数据 - - Implement proper access controls / 实施适当的访问控制 - -4. **Dependencies / 依赖** - - Keep dependencies up to date / 保持依赖最新 - - Use Dependabot for automated updates / 使用 Dependabot 自动更新 - - Review security advisories / 审查安全公告 - -5. **Code Review / 代码审查** - - Review all code changes for security issues / 审查所有代码更改的安全问题 - - Use static analysis tools / 使用静态分析工具 - - Follow secure coding practices / 遵循安全编码实践 - -## Known Security Features / 已知安全特性 - -This application includes the following security features: / 此应用包含以下安全特性: - -- **XSS Protection / XSS 防护:** DOMPurify sanitization and iframe sandboxing / DOMPurify 净化和 iframe 沙箱隔离 -- **CSRF Protection / CSRF 防护:** Flask-WTF token validation / Flask-WTF token 验证 -- **Data Encryption / 数据加密:** Fernet symmetric encryption for sensitive data / Fernet 对称加密敏感数据 -- **Rate Limiting / 速率限制:** Login attempt throttling / 登录尝试限流 -- **Audit Logging / 审计日志:** Sensitive operation tracking / 敏感操作跟踪 -- **Password Hashing / 密码哈希:** bcrypt with salt / bcrypt 加盐 - -For more details, see the security section in README.md / 更多详情请参见 README.md 中的安全部分 - -## Security Updates / 安全更新 - -Security updates will be released as soon as possible after a vulnerability is confirmed. We will: / 安全更新将在漏洞确认后尽快发布。我们将: - -- Release a patch version / 发布补丁版本 -- Publish a security advisory / 发布安全公告 -- Update the CHANGELOG / 更新 CHANGELOG -- Notify users through GitHub releases / 通过 GitHub releases 通知用户 - -## Contact / 联系方式 - -For security-related questions or concerns, please contact: / 有关安全相关的问题或疑虑,请联系: - -- **Email / 邮箱:** 3052147989@qq.com -- **GitHub Issues:** For non-security bugs only / 仅用于非安全 bug - ---- - -Thank you for helping keep this project secure! / 感谢你帮助保持此项目的安全! diff --git a/docs/BUG/BUG-批量操作与验证码提取.md b/docs/BUG/BUG-批量操作与验证码提取.md deleted file mode 100644 index 429c4d0..0000000 --- a/docs/BUG/BUG-批量操作与验证码提取.md +++ /dev/null @@ -1,124 +0,0 @@ -# Bug 列表 - -## BUG-001: Toast 通知位置不正确 ✅ 已修复 - -**优先级**: P1 - 高 - -**描述**: -所有 Toast 通知弹窗显示在屏幕正中间,遮挡用户视线,影响操作体验。 - -**期望行为**: -所有通知统一显示在屏幕右下角(类似常见桌面通知位置)。 - -**修复方案**: -修改 `static/css/main.css` 中的 `.toast` 样式,定位到右下角。 - ---- - -## BUG-002: 切换分组时全选状态被取消 ✅ 已修复 - -**优先级**: P1 - 高 - -**描述**: -用户在默认分组中全选了邮箱,切换到另一个分组后,之前选中的邮箱状态丢失。 - -**修复方案**: -使用全局 `selectedAccountIds` Set 存储选中状态,切换分组时保持。 - ---- - -## BUG-003: 批量操作缺少删除邮箱功能 ✅ 已修复 - -**优先级**: P1 - 高 - -**描述**: -批量操作栏缺少"删除邮箱"功能。 - -**修复方案**: -1. 在 `routes/accounts.py` 中注册 `/api/accounts/batch-delete` 路由 -2. 批量操作栏添加"删除"按钮 -3. 实现 `batchDeleteAccounts()` 函数 - ---- - -## BUG-004: 验证码/链接提取功能异常 ✅ 已修复 - -**优先级**: P0 - 紧急 - -**描述**: -验证码提取 API 只使用 Graph API,未复用 Graph→IMAP 回退策略。 - -**修复方案**: -在 `api_extract_verification` 中添加 IMAP 回退支持(IMAP_SERVER_NEW 和 IMAP_SERVER_OLD)。 - ---- - -## BUG-005: 自动轮询开关类型不一致 ✅ 已修复 - -**优先级**: P0 - 紧急 - -**描述**: -后端返回 `enable_auto_polling` 是 boolean,前端用字符串比较 `=== 'true'`,导致判断永远为 false。 - -**修复方案**: -前端统一使用兼容判断:`data.settings.enable_auto_polling === true || data.settings.enable_auto_polling === 'true'` - ---- - -## BUG-006: 自动轮询未满足 PRD 验收 ✅ 已修复 - -**优先级**: P1 - 高 - -**问题描述**: -1. 通知内容不符合"邮箱地址 + 邮件主题" -2. 红点位置不正确(应该在账号列表项上) -3. 轮询失败不停止且不提示 -4. 静默轮询不满足(启动/停止都 showToast) - -**修复方案**: -1. `showNewEmailNotification()` 增加邮箱地址和邮件主题 -2. 添加 `showAccountNewEmailDot()` 在账号列表项上显示红点 -3. 添加 `pollingErrorCount` 连续错误计数,超过阈值自动停止并提示 -4. `startPolling()` 和 `stopPolling()` 改为静默模式 - ---- - -## BUG-007: 轮询次数校验与提示不一致 ✅ 已修复 - -**优先级**: P2 - 中 - -**描述**: -UI 提示"0 表示持续轮询",但前后端校验都限制 1-100。 - -**修复方案**: -前后端校验改为 0-100,0 表示持续轮询。 - ---- - -## BUG-008: 复制按钮缺少加载/防重复点击 ✅ 已修复 - -**优先级**: P2 - 中 - -**描述**: -复制按钮缺少加载状态、防重复点击、成功/失败视觉反馈。 - -**修复方案**: -1. 添加 `copyVerificationInProgress` 防重复点击 -2. 点击时禁用按钮,显示 ⏳ 加载状态 -3. 成功显示 ✅,失败显示 ❌ -4. 1.5 秒后恢复按钮状态 - ---- - -## 修复状态汇总 - -| Bug ID | 优先级 | 状态 | -|--------|--------|------| -| BUG-001 | P1 | ✅ 已修复 | -| BUG-002 | P1 | ✅ 已修复 | -| BUG-003 | P1 | ✅ 已修复 | -| BUG-004 | P0 | ✅ 已修复 | -| BUG-005 | P0 | ✅ 已修复 | -| BUG-006 | P1 | ✅ 已修复 | -| BUG-007 | P2 | ✅ 已修复 | -| BUG-008 | P2 | ✅ 已修复 | diff --git a/docs/BUGS/BUG-00001-临时邮箱生成失败.md b/docs/BUGS/BUG-00001-临时邮箱生成失败.md deleted file mode 100644 index 9c8070f..0000000 --- a/docs/BUGS/BUG-00001-临时邮箱生成失败.md +++ /dev/null @@ -1,287 +0,0 @@ -# BUG-00001:临时邮箱生成失败 - -## 基本信息 - -- **BUG编号**:BUG-00001 -- **发现日期**:2026-02-23 -- **报告人**:用户 -- **严重程度**:P1(高优先级) -- **状态**:✅ 已修复 -- **影响范围**:临时邮箱功能 -- **修复日期**:2026-02-23 - -## 问题描述 - -### 用户错误信息 -``` -生成临时邮箱失败,请稍后重试 -``` - -### 错误详情 -- **Code**: LEGACY_ERROR -- **Type**: LegacyError -- **Status**: 400 -- **Trace ID**: 557910def72a4ea7a59c8b6fb7880a98 - -## 复现步骤 - -1. 登录系统 -2. 进入临时邮箱管理页面 -3. 点击"生成临时邮箱"按钮 -4. 系统返回错误提示 - -## 预期行为 - -- 成功生成临时邮箱地址 -- 返回邮箱地址给用户 -- 在数据库中记录该临时邮箱 - -## 实际行为 - -- 生成失败 -- 返回笼统的错误信息 -- 用户无法知道具体失败原因 - -## 技术分析 - -### 相关代码位置 - -1. **路由层**:`outlook_web/routes/temp_emails.py:9` - - 路由:`/api/temp-emails/generate` - - 方法:POST - -2. **业务逻辑层**:`outlook_web/legacy.py:4171-4186` - ```python - def api_generate_temp_email(): - """生成新的临时邮箱""" - data = request.json or {} - prefix = data.get('prefix') - domain = data.get('domain') - - email_addr = generate_temp_email(prefix, domain) - - if email_addr: - if add_temp_email(email_addr): - log_audit('create', 'temp_email', email_addr, "生成临时邮箱") - return jsonify({'success': True, 'email': email_addr, 'message': '临时邮箱创建成功'}) - else: - return jsonify({'success': False, 'error': '邮箱已存在'}) - else: - retursonify({'success': False, 'error': '生成临时邮箱失败,请稍后重试'}) - ``` - -3. **服务层**:`outlook_web/services/gptmail.py:42-57` - ```python - def generate_temp_email(prefix: str = None, domain: str = None) -> Optional[str]: - """生成临时邮箱地址""" - json_data = {} - if prefix: - json_data["prefix"] = prefix - if domain: - json_data["domain"] = domain - - if json_data: - result = gptmail_request("POST", "/api/generate-email", json_data=json_data) - else: - result = gptmail_request("GET", "/api/generate-email") - - if result and result.get("success"): - return result.get("data", {}).get("email") - return None - ``` - -4. **错误处理**:`outlook_web/legacy.py:253-267` - - 将字符串错误转换为 LEGACY_ERROR - -### 根本原因分析 - -1. **直接原因**:`generate_temp_email()` 函数返回 `None` - -2. **可能的根本原因**: - - ❌ GPTMail API Key 未配置或无效 - - ❌ GPTMail API 服务不可用 - - ❌ 网络连接问题 - - ❌ API 请求超时(30秒) - - ❌ API 返回数据格式不符合预期 - -3. **错误处理问题**: - - ❌ `gptmail_request` 返回的详细错误信息被丢弃 - - ❌ `generate_temp_email` 只返回 None,不返回错误原因 - - ❌ `api_generate_temp_email` 使用笼统的错误信息 - - ❌ 没有记录详细的错误日志 - -### 错误传播链 - -``` -GPTMail API 失败 - ↓ -gptmail_request() 返回 {"success": False, "error": "具体错误"} - ↓ -generate_temp_email() 返回 None(错误信息丢失) - ↓ -api_generate_temp_email() 返回 "生成临时邮箱失败,请稍后重试" - ↓ -错误处理中间件转换为 LEGACY_ERROR - ↓ -用户看到笼统的错误信息 -``` - -## 修复方案 - -### 方案1:改进错误传播(推荐) - -1. **修改 `generate_temp_email` 函数**: - - 返回 `(email_addr, error_msg)` 元组 - - 保留详细的错误信息 - -2. **修改 `api_generate_temp_email` 函数**: - - 使用详细的错误信息 - - 添加错误日志记录 - -3. **改进 `gptmail_request` 函数**: - - 返回更详细的错误信息 - - 区分不同类型的错误(网络错误、API错误、超时等) - -### 方案2:使用异常处理 - -1. 在 `gptmail_request` 中抛出自定义异常 -2. 在 `api_generate_temp_email` 中捕获并处理异常 -3. 返回详细的错误信息给用户 - -### 推荐实施步骤 - -1. ✅ 创建 BUG 文档 -2. ✅ 改进 `gptmail_request` 函数的错误返回 -3. ✅ 改进 `generate_temp_email` 函数的返回值 -4. ✅ 改进 `api_generate_temp_email` 函数的错误处理 -5. ✅ 添加详细的错误日志 -6. ✅ 更新前端错误提示 -7. ✅ 编写单元测试(11个新测试) -8. ✅ 测试验证(51/51 测试通过) - -## 影响评估 - -- **用户体验**:严重影响,用户无法使用临时邮箱功能 -- **数据完整性**:无影响 -- **系统稳定性**:无影响 -- **安全性**:无影响 - -## 相关文档 - -- PRD:`docs/PRD/Outlook邮件管理工具-重构升级PRD.md` -- TDD:`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` -- 开发者指南:`docs/DEV/00002-前后端拆分-开发者指南.md` - -## 测试计划 - -### 单元测试 -- [x] 测试 `gptmail_request` 的各种错误场景 - - API Key 未配置 - - API Key 无效(401) - - 请求超时 - - 网络连接失败 - - 服务器错误(500) - - 请求频率超限(429) -- [x] 测试 `generate_temp_email` 的错误处理 - - 成功生成 - - API Key 未配置 - - 请求超时 - - 数据格式错误 - - 使用 prefix 和 domain -- [x] 测试 `api_generate_temp_email` 的错误响应(已包含在原有测试中) - -### 集成测试 -- [x] 测试 API Key 无效的场景(通过 mock) -- [x] 测试网络超时的场景(通过 mock) -- [x] 测试 API 服务不可用的场景(通过 mock) -- [x] 测试正常生成临时邮箱的场景 - -### 回归测试 -- [x] 验证其他临时邮箱功能不受影响 -- [x] 验证错误处理中间件正常工作 -- [x] 所有测试通过(51/51) - -## 修复记录 - -- **2026-02-23 20:30**:创建 BUG 文档,完成根本原因分析 -- **2026-02-23 20:45**:实施修复方案 - - 改进 `gptmail_request` 函数,返回详细的错误信息(区分 10+ 种错误类型) - - 改进 `generate_temp_email` 函数,返回 `(email_addr, error_msg)` 元组 - - 改进 `api_generate_temp_email` 函数,使用详细错误信息并添加日志 - - 更新前端错误提示,显示详细错误信息 - - 编写 11 个新的单元测试 - - 更新 2 个现有测试的 mock -- **2026-02-23 21:00**:测试验证通过(51/51 测试全部通过) -- **2026-02-23 21:05**:准备提交修复 - -## 修复详情 - -### 1. 改进 `gptmail_request` 函数(outlook_web/services/gptmail.py) - -**改进内容**: -- 添加 API Key 配置检查 -- 区分 10+ 种错误类型: - - `CONFIG_ERROR`:API Key 未配置 - - `AUTH_ERROR`:API Key 无效(401) - - `PERMISSION_ERROR`:访问被拒绝(403) - - `RATE_LIMIT_ERROR`:请求频率超限(429) - - `SERVER_ERROR`:服务器错误(500+) - - `HTTP_ERROR`:其他 HTTP 错误 - - `TIMEOUT_ERROR`:请求超时 - - `CONNECTION_ERROR`:网络连接失败 - - `REQUEST_ERROR`:其他请求异常 - - `METHOD_ERROR`:不支持的请求方法 - - `UNKNOWN_ERROR`:未知错误 -- 返回结构化错误信息:`{success, error, error_type, details}` - -### 2. 改进 `generate_temp_email` 函数(outlook_web/services/gptmail.py) - -**改进内容**: -- 返回值从 `Optional[str]` 改为 `Tuple[Optional[str], Optional[str]]` -- 成功时返回 `(email_addr, None)` -- 失败时返回 `(None, error_message)`,保留详细错误信息 -- 添加数据格式验证 - -### 3. 改进 `api_generate_temp_email` 函数(outlook_web/legacy.py) - -**改进内容**: -- 解包 `generate_temp_email` 的返回值:`email_addr, error_msg = generate_temp_email(...)` -- 使用详细的错误信息返回给前端 -- 添加日志记录(成功、警告、错误三个级别) - -### 4. 更新前端错误提示(static/js/features/temp_emails.js) - -**改进内容**: -- 检查错误对象的结构 -- 优先显示 `error.message` 或 `error.error` -- 支持结构化错误对象和字符串错误 -- 保留错误详情用于调试 - -### 5. 编写单元测试(tests/test_gptmail_error_handling.py) - -**新增测试**: -- 11 个新的单元测试,覆盖所有错误场景 -- 使用 mock 模拟各种失败情况 -- 验证错误类型和错误信息的正确性 - -## 修复效果 - -### 修复前 -- 用户看到:`生成临时邮箱失败,请稍后重试` -- 错误码:`LEGACY_ERROR` -- 无法知道具体失败原因 - -### 修复后 -- 用户看到详细错误信息,例如: - - `GPTMail API Key 未配置(请在系统设置中配置 GPTMail API Key)` - - `API 请求超时(请求超过 30 秒未响应,请检查网络连接或稍后重试)` - - `无法连接到 GPTMail 服务(网络连接失败: ...)` - - `API Key 无效或已过期(HTTP 401: 请检查 API Key 是否正确)` -- 后端日志记录详细的错误信息 -- 开发者可以快速定位问题 - -## 备注 - -- 建议同时检查其他使用 `gptmail_request` 的函数,确保错误处理一致 -- 考虑添加 GPTMail API 健康检查功能 -- 考虑在设置页面添加 API Key 验证功能 diff --git a/docs/CR/CR-00001-Outlook邮件管理工具-重构升级-文档审查报告.md b/docs/CR/CR-00001-Outlook邮件管理工具-重构升级-文档审查报告.md deleted file mode 100644 index b540792..0000000 --- a/docs/CR/CR-00001-Outlook邮件管理工具-重构升级-文档审查报告.md +++ /dev/null @@ -1,161 +0,0 @@ -# 文档审查报告:Outlook邮件管理工具-重构升级(00001) - -## 📄 文档信息 - -- 主审文档:`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` -- 文档类型:TDD -- 文档编号:未解析到 5 位数字编号(建议统一使用:`00001`) -- 文档标题:Outlook 邮件管理工具|重构升级 TDD(技术交付清单,仅说明做什么) -- 存放路径:`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` -- 关联前置文档(同主题,因缺少编号以标题关联): - - `docs/PRD/Outlook邮件管理工具-重构升级PRD.md` - - `docs/FD/Outlook邮件管理工具-重构升级FD.md` - - `docs/TODO/Outlook邮件管理工具-重构升级TODO.md` - -## 📚 规范与架构参考文件读取结果 - -- 文档规范: - - 未找到 `Rules/开发文档规范.md` - - 未找到 `Rules/项目文档规范.md` -- 项目架构/产品 Steering: - - 未找到 `.kiro/steering/structure.md` - - 未找到 `.kiro/steering/tech.md` - - 未找到 `.kiro/steering/product.md` - -> 影响:部分“格式/必备章节/架构符合性”无法严格按项目规范判定,本报告采用你提供的审查维度清单做最佳努力评估。 - ---- - -## 📊 质量评分 - -- 格式规范性:4/10 -- 内容完整性:5/10 -- 开发可行性:5/10 -- 架构符合性:3/10 -- 功能集成性:3/10 -- 整体评分:4/10 - ---- - -## ✅ 优点 - -- PRD 内容结构完整,包含背景、用户画像/故事、功能需求、非功能需求与验收清单(`docs/PRD/Outlook邮件管理工具-重构升级PRD.md`)。 -- FD/TDD/TODO 在“只说明要完成什么”的约束下,覆盖面较全,且三者之间方向一致(`docs/FD/Outlook邮件管理工具-重构升级FD.md`、`docs/TDD/Outlook邮件管理工具-重构升级TDD.md`、`docs/TODO/Outlook邮件管理工具-重构升级TODO.md`)。 -- TDD 聚焦了稳定性/一致性/可观测等关键技术交付点,并提供了发布回归检查范围(`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` 3.10)。 - ---- - -## ⚠️ 需要改进项 - -### 1) 格式规范(命名/编号/类型) - -- 当前文档命名未体现“类型-编号-描述”的规则,例如: - - `docs/PRD/Outlook邮件管理工具-重构升级PRD.md` - - `docs/FD/Outlook邮件管理工具-重构升级FD.md` - - `docs/TDD/Outlook邮件管理工具-重构升级TDD.md` -- 文档编号缺失,导致无法自动关联同编号前置文档。 -- `TODO` 是否属于允许的文档类型不明确(你给出的类型集合中未包含 TODO)。 - -### 2) 内容完整性(按你提供的审查维度) - -- FD 目前以“功能清单”为主,但缺少“业务流程/交互逻辑”的明确描述,不利于测试用例与验收边界一致(`docs/FD/Outlook邮件管理工具-重构升级FD.md`)。 -- TDD 目前以“技术交付清单”为主,但缺少你定义的 TDD 必备内容(技术架构、API 设计、数据设计等),导致开发落地仍需要额外补充设计文档(`docs/TDD/Outlook邮件管理工具-重构升级TDD.md`)。 - -### 3) 可验收口径仍偏抽象 - -- PRD/FD/TDD 对“可接受时间”“性能基线”给了方向,但缺少明确的验收口径(例如用什么页面/操作测、阈值是什么)(`docs/PRD/Outlook邮件管理工具-重构升级PRD.md` 6.2;`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` 3.8)。 - ---- - -## ❌ 严重问题 - -### S-001:编号缺失导致无法按规则自动关联 - -- 问题描述:无法从文件名解析出 5 位编号,无法按编号自动找到前置文档(PRD/FD/TDD 等)。 -- 影响:一致性检查流程无法自动化,后续文档增多时会迅速失控。 - -### S-002:项目文档规范与 Steering 文件缺失 - -- 问题描述:未找到 `Rules/开发文档规范.md`/`Rules/项目文档规范.md` 与 `.kiro/steering/*`。 -- 影响:无法对“章节必备项、文档类型集合、架构符合性”进行权威判定;审查标准会随人变化。 - -### S-003:TDD 定义与当前写法存在“规范冲突” - -- 你提供的审查维度要求 TDD 包含:技术架构、API 设计、数据库设计是否详细。 -- 但当前 TDD 明确写为“仅说明做什么,不描述实现过程与编码细节”(`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` 1)。 -- 影响:若按你给出的审查标准执行,当前 TDD 会被判为“内容不完整”;需要你确认 TDD 的定位到底是“技术设计”还是“技术交付清单”。 - ---- - -## 🔗 前置文档一致性 - -- PRD → FD: - - 一致性:总体一致(FD 覆盖 PRD 的主要功能块:登录、分组、账号、标签、邮件、刷新、日志、导出、审计、OAuth2、临时邮箱、设置)。 - - 风险:PRD 的“成功指标/验收”在 FD 中未全部转换为可操作的验收点(更多是功能覆盖,缺少边界与异常流)。 -- FD → TDD: - - 一致性:方向一致(TDD 聚焦稳定性/一致性/错误与追踪、升级保障、可观测等,能支撑 FD 的业务功能保真)。 - - 缺口:TDD 未提供“功能 → 入口 → 接口/数据 → 日志/审计”的映射关系,导致集成完整性无法直接验收。 -- TODO 与 FD/TDD: - - 一致性:TODO 基本覆盖 FD/TDD 的关键点,并按 P0/P1 分级(`docs/TODO/Outlook邮件管理工具-重构升级TODO.md`)。 - ---- - -## 🔗 功能集成完整性(🔴 新增) - -> 说明:你提供的集成检查清单以 Android 项目为例(导航/DI/数据库等)。本项目为 Web 管理工具,报告采用“入口点可达性 + 调用链完整性 + 状态/任务运行可验证性”的等价口径进行检查。 - -### 功能组件清单(文档抽取) - -| 组件类型 | 组件名称 | 集成状态 | -|---------|---------|---------| -| 页面/入口 | 登录、主页、分组/账号/邮件/设置等入口 | ⚠️ 文档未明确“入口点清单” | -| 接口/能力 | 账号/分组/标签/邮件读取/删除/导出/刷新/日志/统计 | ⚠️ 文档未列出接口清单与契约 | -| 后台任务 | 定时刷新 | ⚠️ 文档未明确“运行证据获取方式” | -| 数据对象 | 账号、分组、标签、设置、刷新日志、审计日志、临时邮箱等 | ⚠️ 文档未明确数据字典/字段口径 | -| 观测点 | 追踪标识、错误编号、运行状态、健康状态 | ⚠️ 文档未明确展示位置/查询口径 | - -### 集成点验证(以“文档可验收”为准) - -| 集成点 | 需要在文档中明确的内容 | 状态 | 说明 | -|--------|------------------------|------|------| -| 入口点可达 | 每个功能从哪里进入(页面/按钮/菜单/链接) | ❌ | FD/TDD/TODO 仅描述能力,未提供入口点清单 | -| 调用链完整 | 关键功能的处理链路边界(至少到“对外行为层面”) | ⚠️ | TDD 有交付项但缺少“功能-接口-数据-日志”映射 | -| 后台任务可运行 | 定时刷新如何被确认“正在运行” | ⚠️ | TDD 要求可验证,但未定义验证口径与展示位置 | -| 状态一致性 | 二次验证、锁定等状态在重启/扩容下的验收方法 | ⚠️ | TDD 有要求,但缺少验收步骤与判定标准 | -| 错误追踪 | 追踪标识在何处生成/展示/查询 | ⚠️ | TDD 有要求,但缺少展示与查询口径 | - -### ❌ 集成缺失问题(文档层面) - -#### INT-001:缺少“功能入口点清单” - -- 问题描述:无法从文档直接验收“功能是否可访问”,容易出现“功能实现了但用户找不到入口”的遗漏。 -- 建议补充:在 FD 或 TDD 增加“入口点清单”章节,列出每个功能的入口位置与可达路径。 - -#### INT-002:缺少“接口/契约清单” - -- 问题描述:文档未定义关键能力的接口列表、请求/响应字段与错误结构,导致实现/测试/验收口径不一致。 -- 建议补充:增加“接口清单(仅契约)”,至少覆盖读取/刷新/删除/导出/设置/临时邮箱等关键能力。 - ---- - -## 📋 改进建议(按优先级) - -### P0:先补齐“规范与编号”,让审查流程能跑起来 - -1. 统一为该主题分配编号 `00001`,并将 PRD/FD/TDD/TODO 文件名改为 `类型-00001-描述.md` 的形式。 -2. 在仓库补充文档规范文件(至少一种):`Rules/项目文档规范.md`(或 `Rules/开发文档规范.md`),明确: - - 允许的文档类型集合 - - 命名规则与存放路径规则 - - 各类型文档的必备章节模板 - -### P0:明确 TDD 的定位,避免“规范冲突” - -3. 你需要确认:TDD 是“技术设计文档”还是“技术交付清单”。 - - 若是“技术设计文档”:需要补齐技术架构、接口契约、数据字典、错误策略、运行与集成验收口径。 - - 若是“技术交付清单”:建议新增一个“设计型文档”(例如 AD/TD/IMPL)承载架构/API/数据设计与集成映射,避免把 TDD 变成两种角色的混合体。 - -### P1:让 FD/TDD 具备可直接验收的“入口/接口/异常流” - -4. FD 补齐关键业务流程与交互逻辑(至少覆盖:导入、全量刷新进度、失败重试、导出二次验证、定时刷新验证)。 -5. TDD 增加“功能集成映射”章节(入口点清单、接口清单、错误编号清单、追踪标识展示/查询口径、定时刷新运行证据口径)。 - diff --git a/docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md b/docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md deleted file mode 100644 index 59f6859..0000000 --- a/docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md +++ /dev/null @@ -1,154 +0,0 @@ -# 文档审查报告:Outlook邮件管理工具-前后端拆分与模块化(00002) - -## 📄 文档信息 - -- 主审文档:`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md` -- 文档类型:TDD -- 文档编号:未解析到 5 位数字编号(建议统一分配编号并落到文件名:如 `TDD-00002-前后端拆分与模块化.md`) -- 文档标题:Outlook 邮件管理工具|前后端拆分与模块化 TDD(技术设计细节) -- 存放路径:`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md` -- 关联前置文档(同主题,因缺少编号以标题关联): - - `docs/PRD/Outlook邮件管理工具-前后端拆分与模块化PRD.md` - - `docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md` - - `docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md` - -## 📚 规范与架构参考文件读取结果 - -- 文档规范: - - 未找到 `Rules/开发文档规范.md` - - 未找到 `Rules/项目文档规范.md` -- 项目架构/产品 Steering: - - 未找到 `.kiro/steering/structure.md` - - 未找到 `.kiro/steering/tech.md` - - 未找到 `.kiro/steering/product.md` - -> 影响:部分“命名/必备章节/项目架构符合性”无法按项目权威规范判定。本报告按你提供的审查维度清单做最佳努力评估,并基于本仓库(Python/Flask)现实情况对“集成点检查”做等价映射(见下文“功能集成完整性”)。 - ---- - -## 📊 质量评分 - -- 格式规范性:5/10 -- 内容完整性:7/10 -- 开发可行性:7/10 -- 架构符合性:7/10 -- 功能集成性:6/10 -- 整体评分:6.4/10 - ---- - -## ✅ 优点 - -- **拆分目标清晰且可回滚**:明确“行为兼容 + 渐进式拆分 + 每阶段可验收/可回滚”,适合在现有稳定版本上做结构重构(`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md` 2/6)。 -- **分层边界与依赖方向写得明确**:给出了 `routes -> services -> repositories -> db` 的依赖约束,并强调避免循环依赖(3.1、8)。 -- **关键风险点识别到位**:import-time 副作用(调度器线程)、契约漂移、前端全局变量依赖等风险都有对应缓解策略(4.2、4.9、8)。 -- **与 PRD/FD 一致**:PRD/FD 的“对用户无感、不引入新复杂度、保持接口不变”的核心约束,在 TDD 中被复述并落到迁移阶段与测试门禁(PRD 2/3/5,FD 2,TDD 2/6/7)。 - ---- - -## ⚠️ 需要改进项 - -### 1) 格式规范(命名/编号/类型) - -- 当前文档命名未体现“类型-编号-描述”的规则(无法自动按编号关联前置文档)。 -- 你提供的文档类型集合里未包含 `TODO`,但仓库中大量使用 TODO 文档;建议在“项目文档规范”中明确是否允许 TODO 作为文档类型。 - -### 2) 内容完整性(TDD 维度) - -> 当前 TDD 已是“技术设计细节”,明显比旧的“技术交付清单型 TDD”更可落地;但仍建议补齐两类“可直接验收/可直接开发”的信息。 - -- **缺少“接口契约抽样清单”**:虽强调 URL/响应不变,但未列出需保持不变的关键接口清单与最小字段契约(建议附录列出 10~20 个关键端点与关键字段)。 -- **缺少“迁移后的模块间契约”**:例如 repository 返回的数据形态、service 的错误聚合结构是否必须与现有一致,建议用 1~2 个典型链路给出示例(例如“删除邮件三路回退错误聚合”)。 - -### 3) 前端拆分的“切割策略”可再具体 - -- 当前给了 `api/ui/features/state` 的理想结构,但未说明迁移时如何处理: - - 现有全局函数/事件绑定的过渡方案(例如先 `main.js` 保持旧函数名,再逐步导出/内聚) - - 静态资源缓存/版本策略(避免浏览器缓存导致上线后脚本不更新) - ---- - -## ❌ 严重问题 - -### S-001:缺少项目级“文档规范/架构 Steering”,导致审查标准难以固化 - -- 问题描述:未找到 `Rules/*` 与 `.kiro/steering/*`,无法严格按项目规范判定“必备章节/命名/目录结构”。 -- 影响:后续文档增多时一致性会下降,审查结论难以复用。 -- 建议:补一份最小可用的 `Rules/项目文档规范.md`(至少定义:允许的文档类型、命名规则、存放路径、各类型必备章节)。 - ---- - -## 🔗 前置文档一致性 - -- PRD → FD: - - 一致性:✅(FD 的交付项覆盖 PRD 目标:后端模块化、入口兼容、前端静态资源拆分、测试与文档交付)。 - - 风险:⚠️(FD 对“接口契约不变”的验收口径仍偏抽象,可在 FD/TODO 中增加“抽样验收接口清单”以便执行) -- FD → TDD: - - 一致性:✅(TDD 细化了 FD 的拆分交付项:目录结构、装配策略、依赖方向、分阶段迁移与测试门禁) - - 缺口:⚠️(TDD 尚未给出“最小契约清单”和“典型链路示例”,导致实现时仍可能出现局部行为漂移) -- TDD → TODO: - - 一致性:✅(TODO 的阶段拆解与 TDD 6 章一致) - ---- - -## 🔗 功能集成完整性检查(重点,适用 TDD) - -> 说明:你提供的集成点清单以 Android(导航/DI/Room/Hilt)为例。本仓库为 Python/Flask Web 项目,这里采用等价映射: -> -> - “导航集成” ≈ Blueprint/路由注册 + 前端入口脚本可达 -> - “DI 模块集成” ≈ app 装配(create_app)/依赖装配/初始化顺序 -> - “数据库集成” ≈ init_db/迁移/连接生命周期 -> - “入口点集成” ≈ 页面/按钮/路由是否可访问 -> - “调用链完整” ≈ routes → services → repositories → db - -### 功能组件清单(从 TDD 抽取) - -| 组件类型 | 组件名称(建议) | 集成状态 | -|---------|------------------|---------| -| App 装配 | `outlook_web.app:create_app()` | ⚠️ 设计已明确,尚需落地并补 smoke test | -| 路由注册 | `outlook_web.routes.*`(Blueprint) | ⚠️ 设计已明确,尚需落地与契约抽样测试 | -| 服务层 | `outlook_web.services.*` | ⚠️ 设计已明确,尚需迁移与单测 | -| 仓储层 | `outlook_web.repositories.*` | ⚠️ 设计已明确,尚需迁移与单测 | -| DB/迁移 | `outlook_web.db:init_db()` | ⚠️ 设计已明确,需验证与现有迁移一致 | -| 调度器 | `outlook_web.scheduler.*` | ⚠️ 设计已明确,重点验证 import-time 副作用 | -| 前端入口 | `/static/js/main.js` | ⚠️ 设计已明确,需验证模板引用与缓存策略 | - -### 集成点验证(以“文档可验收”为准) - -| 集成点 | 文档中是否明确 | 状态 | 说明 | -|--------|----------------|------|------| -| 路由/导航注册 | ✅ | ✅ | 明确 Blueprint 注册方式与 URL 不变策略(4.8) | -| 装配/初始化顺序 | ✅ | ✅ | `init_db()` → scheduler 开关启动(4.9) | -| 调用链完整性 | ✅ | ✅ | 分层依赖方向写明(3.1) | -| 静态资源入口 | ✅ | ⚠️ | 明确 `` 引用静态资源(不强制使用 ESModule,避免大规模重写与行为回归)。 -- 回滚点: - - 若外链资源加载/缓存导致页面不可用,优先回退到上一绿色基线(Git 回退/恢复上一版本镜像/回滚发布包)。 -- 切换方式: - - 当前未引入运行开关;如需要“运行侧快速切换内联/外链”,可在后续阶段增加开关(例如 `FRONTEND_EXTERNAL_ASSETS=true|false`),但需同步补齐门禁与回归验证。 - -### 阶段 6:文档与维护指南 - -- 目标: - - README/开发者文档补齐模块职责、依赖方向、测试运行方式与迁移说明。 -- 回滚点: - - 文档变更不影响运行;但若文档与实现不一致,应以实现为准并尽快纠正文档。 diff --git a/docs/RUN/运行与定时刷新验证指南.md b/docs/RUN/运行与定时刷新验证指南.md deleted file mode 100644 index cece03b..0000000 --- a/docs/RUN/运行与定时刷新验证指南.md +++ /dev/null @@ -1,70 +0,0 @@ -# 运行与定时刷新验证指南 - -- 日期:2026-02-22 -- 目的:给出“官方推荐运行方式”与“定时刷新真实运行”的可验证口径(接口 + 指标)。 - ---- - -## 1) 官方推荐运行方式(建议) - -### Docker(推荐) - -使用仓库提供的 `Dockerfile`(内部使用 Gunicorn 启动,默认 `-w 1`,避免多进程重复启动调度器与 session 共享问题)。 - -关键点: -- 必须设置 `SECRET_KEY` -- 建议设置 `SCHEDULER_AUTOSTART=true`(默认即为 true),用于自动启动后台调度器(心跳 + 定时刷新) -- 持久化挂载 `./data:/app/data`,避免重启后丢数据 - -### 本地直接运行(开发/调试) - -```bash -pip install -r requirements.txt -export SECRET_KEY=xxx -python web_outlook_app.py -``` - -> 注意:`flask run` 的 reloader 可能导致重复启动;若使用,请确认 `SCHEDULER_AUTOSTART` 行为符合预期。 - ---- - -## 2) 如何验证“定时刷新真的在跑” - -### A. 快速健康检查(管理员) - -- 接口:`GET /api/system/health` -- 关注字段: - - `health.database`:数据库是否可用 - - `health.scheduler.enabled`:是否启用定时刷新 - - `health.scheduler.heartbeat_age_seconds`:心跳距离当前的秒数 - - `health.scheduler.healthy`:是否健康(默认阈值:`<= 120s`) - - `health.refresh.locked / running`:是否存在刷新锁/运行中刷新 - -### B. 调度器状态与“未来触发预览” - -- 接口:`GET /api/scheduler/status` -- 关注字段: - - `scheduler.enabled / use_cron`:是否启用、调度方式 - - `scheduler.future_runs / next_due`:未来触发时间预览 - - `scheduler.heartbeat_age_seconds`:调度器心跳年龄 - - `refresh.last_scheduled`:最近一次定时刷新(含触发来源、总数/成功/失败、trace_id) - - `refresh.running`:是否存在运行中的刷新(running 状态) - - `refresh.lock`:分布式锁状态(用于冲突控制) - -### C. 诊断信息(状态一致性/清理策略) - -- 接口:`GET /api/system/diagnostics` -- 关注字段: - - `export_verify_tokens_active`:当前有效导出二次验证 token 数量 - - `login_locked_ip_count`:当前被锁定的 IP 数量 - - `locks`:分布式锁列表 - - `running_runs / last_runs`:刷新运行记录 - - `schema`:数据库升级/版本状态(是否最新、最近一次迁移记录) - -### D. 升级过程可验证(数据兼容/升级保障) - -- 接口:`GET /api/system/upgrade-status` -- 关注字段: - - `schema_version / target_version / up_to_date`:当前版本与目标版本 - - `last_trace_id / last_error`:最近一次升级的追踪标识与错误信息(便于定位) - - `last_migration`:最近一次迁移记录(status/时间/trace_id) diff --git a/docs/TD/TD-可调整布局系统.md b/docs/TD/TD-可调整布局系统.md deleted file mode 100644 index c0f5f40..0000000 --- a/docs/TD/TD-可调整布局系统.md +++ /dev/null @@ -1,633 +0,0 @@ -# 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
2. 按下鼠标左键
3. 向右拖动 50px
4. 释放鼠标 | -| **预期结果** | 1. resizer 显示 hover 效果(背景色变化)
2. 光标变为 col-resize
3. 面板宽度实时更新为 250px
4. 拖动流畅,无卡顿 | -| **关联需求** | F1.1, F1.3 | - -#### TC-F1-002-B: 拖动分组面板到最小宽度 - -| 项目 | 内容 | -|------|------| -| **优先级** | P0 | -| **前置条件** | 页面已加载,分组面板宽度为默认 200px | -| **测试步骤** | 1. 鼠标按下 resizer
2. 向左拖动 100px(超过最小宽度限制)
3. 释放鼠标 | -| **预期结果** | 面板宽度停止在最小宽度 150px,不会继续缩小 | -| **关联需求** | F1.2 | - -#### TC-F1-003-B: 拖动分组面板到最大宽度 - -| 项目 | 内容 | -|------|------| -| **优先级** | P0 | -| **前置条件** | 页面已加载,分组面板宽度为默认 200px | -| **测试步骤** | 1. 鼠标按下 resizer
2. 向右拖动 300px(超过最大宽度限制)
3. 释放鼠标 | -| **预期结果** | 面板宽度停止在最大宽度 400px,不会继续增大 | -| **关联需求** | F1.2 | - -#### TC-F1-004-B: 拖动时确保邮件详情面板至少 400px - -| 项目 | 内容 | -|------|------| -| **优先级** | P0 | -| **前置条件** | 窗口宽度 1200px,所有面板展开 | -| **测试步骤** | 1. 拖动分组面板到最大宽度 400px
2. 拖动账号面板到最大宽度 500px
3. 尝试拖动邮件列表面板到最大宽度 600px | -| **预期结果** | 邮件列表面板宽度被限制,确保邮件详情面板至少保持 400px | -| **关联需求** | F1.2 | - -#### TC-F1-005-N: 快速拖动导致鼠标移出窗口 - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | 页面已加载 | -| **测试步骤** | 1. 鼠标按下 resizer
2. 快速向右拖动,鼠标移出浏览器窗口
3. 在窗口外释放鼠标
4. 鼠标移回窗口 | -| **预期结果** | 1. 拖动正常结束
2. 光标恢复正常
3. 状态已保存 | -| **关联需求** | F1.1 | - -#### TC-F1-006-PF: 拖动帧率测试 - -| 项目 | 内容 | -|------|------| -| **优先级** | P0 | -| **前置条件** | 页面已加载 | -| **测试步骤** | 1. 使用 Performance API 监听帧率
2. 连续拖动 1 秒
3. 计算平均帧率 | -| **预期结果** | 平均帧率 ≥ 60fps(允许 5fps 误差) | -| **关联需求** | F1.4 | - -### 4.3 F2 - 面板折叠/展开测试用例 - -#### TC-F2-001-P: 点击折叠按钮折叠面板 - -| 项目 | 内容 | -|------|------| -| **优先级** | P0 | -| **前置条件** | 分组面板处于展开状态 | -| **测试步骤** | 1. 点击分组面板右上角的折叠按钮 | -| **预期结果** | 1. 面板宽度变为 0
2. 面板内容不可见
3. 显示折叠指示条(48px 宽)
4. 折叠动画流畅(200ms)
5. 按钮图标变为 →
6. aria-expanded 更新为 false | -| **关联需求** | F2.1, F2.2, F2.3, F2.5 | - -#### TC-F2-002-P: 点击折叠指示条展开面板 - -| 项目 | 内容 | -|------|------| -| **优先级** | P0 | -| **前置条件** | 分组面板处于折叠状态 | -| **测试步骤** | 1. 点击折叠指示条 | -| **预期结果** | 1. 面板恢复到折叠前的宽度
2. 面板内容可见
3. 折叠指示条消失
4. 展开动画流畅(200ms)
5. 按钮图标变为 ←
6. aria-expanded 更新为 true | -| **关联需求** | F2.1, F2.2, F2.3, F2.5 | - -#### TC-F2-003-N: 尝试折叠所有面板 - -| 项目 | 内容 | -|------|------| -| **优先级** | P0 | -| **前置条件** | 只有分组面板展开,其他面板已折叠 | -| **测试步骤** | 1. 点击分组面板的折叠按钮 | -| **预期结果** | 1. 显示提示信息:"至少需要保持一个面板展开"
2. 分组面板保持展开状态 | -| **关联需求** | F2.4 | - -#### TC-F2-004-P: 折叠后 resizer 不可见 - -| 项目 | 内容 | -|------|------| -| **优先级** | P0 | -| **前置条件** | 分组面板处于展开状态 | -| **测试步骤** | 1. 折叠分组面板
2. 检查 resizer 元素 | -| **预期结果** | 1. resizer 不可见(display: none)
2. resizer 不可交互(pointer-events: none) | -| **关联需求** | F2.6 | - -#### TC-F2-005-N: 折叠状态下尝试拖动 - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | 分组面板处于折叠状态 | -| **测试步骤** | 1. 尝试在折叠指示条位置拖动 | -| **预期结果** | 无法拖动,必须先展开面板 | -| **关联需求** | F2.6 | - -### 4.4 F3 - 恢复默认布局测试用例 - -#### TC-F3-001-P: 恢复默认布局 - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | 用户已调整布局(宽度和折叠状态) | -| **测试步骤** | 1. 点击顶部导航栏的"恢复默认布局"按钮
2. 在确认对话框中点击"确定" | -| **预期结果** | 1. 分组面板宽度恢复为 200px
2. 账号面板宽度恢复为 260px
3. 邮件列表面板宽度恢复为 380px
4. 所有面板展开
5. localStorage 中的状态被清除
6. 恢复动画流畅(300ms) | -| **关联需求** | F3.1, F3.2, F3.3, F3.4 | - -#### TC-F3-002-N: 取消恢复默认布局 - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | 用户已调整布局 | -| **测试步骤** | 1. 点击"恢复默认布局"按钮
2. 在确认对话框中点击"取消" | -| **预期结果** | 1. 对话框关闭
2. 布局保持当前状态不变 | -| **关联需求** | F3.2 | - -### 4.5 F4 - 状态持久化测试用例 - -#### TC-F4-001-P: 拖动后保存状态 - -| 项目 | 内容 | -|------|------| -| **优先级** | P0 | -| **前置条件** | 页面已加载 | -| **测试步骤** | 1. 拖动分组面板到 250px
2. 等待 500ms(防抖时间)
3. 检查 localStorage | -| **预期结果** | localStorage 中保值 "250px" | -| **关联需求** | F4.1 | - -#### TC-F4-002-P: 折叠后立即保存状态 - -| 项目 | 内容 | -|------|------| -| **优先级** | P0 | -| **前置条件** | 页面已加载 | -| **测试步骤** | 1. 折叠分组面板
2. 立即检查 localStorage(无需等待) | -| **预期结果** | localStorage 中保存了折叠状态 collapsed: true | -| **关联需求** | F4.2 | - -#### TC-F4-003-P: 页面刷新后恢复状态 - -| 项目 | 内容 | -|------|------| -| **优先级** | P0 | -| **前置条件** | 用户已调整布局并保存 | -| **测试步骤** | 1. 刷新页面(F5)
2. 等待页面加载完成 | -| **预期结果** | 1. 面板宽度恢复到调整后的值
2. 折叠状态恢复
3. 恢复过程无闪烁 | -| **关联需求** | F4.3 | - -#### TC-F4-004-P: 多用户布局隔离 - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | 系统支持多用户 | -| **测试步骤** | 1. 用户 A 登录,调整布局
2. 用户 A 退出
3. 用户 B 登录
4. 检查布局状态 | -| **预期结果** | 用户 B 看到的是默认布局,不受用户 A 的影响 | -| **关联需求** | F4.4 | - -#### TC-F4-005-P: 页面卸载时强制保存 - -| 项目 | 内容 | -|------|------| -| **优先级** | P0 | -| **前置条件** | 页面已加载 | -| **测试步骤** | 1. 拖动面板
2. 立即关闭浏览器标签页(不等待防抖)
3. 重新打开页面 | -| **预期结果** | 布局状态已保存,最后一次调整未丢失 | -| **关联需求** | F4.5 | - -#### TC-F4-006-N: localStorage 不可用 - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | 浏览器禁用 localStorage 或隐私模式 | -| **测试步骤** | 1. 加载页面
2. 调整布局
3. 刷新页面 | -| **预期结果** | 1. 页面正常加载,使用默认布局
2. 调整布局功能正常
3. 刷新后恢复默认布局(无法持久化)
4. 控制台显示警告n| **关联需求** | F4.3 | - -#### TC-F4-007-N: localStorage 数据损坏 - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | localStorage 中存在损坏的数据 | -| **测试步骤** | 1. 手动修改 localStorage 数据为无效 JSON
2. 刷新页面 | -| **预期结果** | 1. 页面正常加载
2. 使用默认布局
3. 损坏的数据被清除
4. 控制台显示错误信息 | -| **关联需求** | F4.3 | - -### 4.6 F5 - 窗口尺寸适配测试用例 - -#### TC-F5-001-P: 窗口宽度 < 1200px 自动折叠分组面板 - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | 窗口宽度 > 1200px,所有面板展开 | -| **测试步骤** | 1. 调整浏览器窗口宽度到 1100px
2. 等待 200ms(防抖时间) | -| **预期结果** | 1. 分组面板自动折叠
2. 面板标记为 autoCollapsed
3. 其他面板保持展开 | -| **关联需求** | F5.1 | - -#### TC-F5-002-P: 窗口宽度 < 900px 自动折叠账号面板 - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | 窗口宽度 > 900px | -| **测试步骤** | 1. 调整浏览器窗口宽度到 800px
2. 等待 200ms | -| **预期结果** | 1. 分组面板和账号面板自动折叠
2. 邮件列表和详情面板保持展开 | -| **关联需求** | F5.1 | - -#### TC-F5-003-P: 窗口宽度 < 700px 自动折叠邮件列表面板 - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | 窗口宽度 > 700px | -| **测试步骤** | 1. 调整浏览器窗口宽度到 600px
2. 等待 200ms | -| **预期结果** | 1. 所有侧边栏面板自动折叠
2. 只显示邮件详情面板 | -| **关联需求** | F5.1 | - -#### TC-F5-004-P: 窗口放大后自动恢复面板 - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | 窗口宽度 < 1200px,分组面板自动折叠 | -| **测试步骤** | 1. 调整浏览器窗口宽度到 1300px
2. 等待 200ms | -| **预期结果** | 1. 分组面板自动展开
2. autoCd 标记被移除 | -| **关联需求** | F5.2 | - -#### TC-F5-005-N: 用户手动折叠的面板不自动恢复 - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | 窗口宽度 > 1200px | -| **测试步骤** | 1. 用户手动折叠分组面板
2. 调整窗口宽度到 1100px 再恢复到 1300px | -| **预期结果** | 分组面板保持折叠状态(因为是用户手动折叠) | -| **关联需求** | F5.2 | - -### 4.7 F6 - 键盘可访问性测试用例 - -#### TC-F6-001-P: Tab 键聚焦到 resizer - -| 项目 | 内容 | -|------|------| -| **优先级** | P1 | -| **前置条件** | 页面已加载 | -| **测试步骤** | 1. 按 Tab 键多次,直到聚焦到分组面板的 resizer | -| **预期结果** | 1. resizer 获得焦点
2. 显示明显的视觉反馈(outline 或高亮边框) | -| **关联需求** | F6.1 | - -#### TC-F6-002-P: 方向键调整宽度 - -| 项目 | 内容 | -|---------| -| **优先级** | P1 | -| **前置条件** | resizer 已聚焦,面板宽度 200px | -| **测试步骤** | 1. 按右箭头键 5 次
2. 按左箭头键 3 次 | -| **预期结果** | 1. 每次按右箭头,宽度增加 10px(最终 250px)
2. 每次按左箭头,宽度减少 10px(最终 220px)
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"
2. aria-orientation="vertical"
3. aria-label="调整[面板名称]宽度"
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% - ---- - -**文档结束** diff --git a/docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md b/docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md deleted file mode 100644 index 298c414..0000000 --- a/docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md +++ /dev/null @@ -1,1033 +0,0 @@ -# Outlook 邮件管理工具|Legacy 迁移到 Controllers 层 TDD(技术设计细节) - -- 文档状态:草案 -- 版本:V1.0 -- 日期:2026-02-24 -- 对齐 PRD:`docs/PRD/PRD-00003-Legacy代码拆分到Controllers层.md` -- 对齐 FD:`docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md` -- 对齐开发指南:`docs/DEV/00002-前后端拆分-开发者指南.md` - ---- - -## 1. 文档目的 - -本 TDD 用于描述"为满足 PRD/FD,Legacy 迁移到 Controllers 层需要采用的技术设计细节"。 - -重点回答: - -- 如何设计 Controllers 层的标准模式和职责边界 -- 如何从 `impl=legacy` 模式迁移到直接导入 controller 模式 -- 如何保证 **URL/响应契约/错误结构/trace_id** 不回归 -- 如何实现分阶段迁移(每阶段可回滚、可验证、可测试) -- 如何避免循环依赖和全局状态问题 -- 如何设计测试策略(单元测试、集成测试) - ---- - -## 2. 设计原则与硬约束(必须满足) - -### 2.1 行为兼容性(对用户无感) - -- 保持现有 API 路径/方法/参数语义/响应结构不变 -- 保持统一错误结构、`trace_id` 透传、默认脱敏策略不变 -- 保持登录/会话/CSRF 行为不变 -- 保持中间件和错误处理器行为不变 - -### 2.2 渐进式迁移(可回滚、可验证) - -- 任何阶段都必须可运行且可回归:至少通过 `python -m unittest discover -s tests -v` 与关键接口抽样验收 -- 每个阶段完成后提交 Git,便于回滚 -- 保留 legacy.py 直到所有模块迁移完成 - -### 2.3 不引入新复杂度 - -- 不引入新的框架或库 -- 不改变现有的技术栈(Flask + SQLite) -- 不引入复杂的依赖注入框架 -- 保持代码简单、直接、易于理解 - -### 2.4 性能要求 - -- 响应时间不超过迁移前的 110% -- 内存使用无明显增加 -- 启动时间无明显增加 - ---- - -## 3. 总体架构设计 - -### 3.1 四层架构 - -采用清晰的分层架构,职责分离: - -``` -┌─────────────────────────────────────────────────────────┐ -│ Routes 层(路由注册) │ -│ - URL 到函数的映射 │ -│ - Blueprint 创建和注册 │ -│ - 不包含业务逻辑 │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌────────────────────────────────────────────────┐ -│ Controllers 层(请求处理)【新增】 │ -│ - 参数解析和验证 │ -│ - 鉴权检查(@login_required) │ -│ - 调用 Services/Repositories │ -│ - 响应封装(jsonify) │ -│ - 错误处理(try-except) │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ Services 层(业务逻辑)【已有】 │ -│ - 业务流程编排 │ -│ - 多后端回退(Graph API → IMAP) │ -│ - 不依赖 Flask request/response │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ Repositories 层(数据访问)【已有】 │ -│ - SQL 查询和数据映射 │ -│ - 不包含业务逻辑 │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ Database(SQLite) │ -└─────────────────────────────────────────────────────────┘ -``` - -### 3.2 依赖方向约束(必须遵守) - -``` -routes → controllers → services → repositories → db -``` - -**禁止反向依赖:** -- repositories 不能依赖 services -- services 不能依赖 controllers -- controllers 不能依赖 routes - -**横切关注点(可被任何层依赖):** -- `config` - 配置管理 -- `errors` - 错误模型 -- `security` - 安全模块 -- `audit` - 审计日志 -- `db` - 数据库连接 - -### 3.3 当前架构 vs 目标架构 - -**当前架构(迁移前):** -``` -routes/ (Blueprint) → legacy.py (5757行) → services/ → repositories/ - ↓ - 包含所有路由处理逻辑 -``` - -**目标架构(迁移后):** -``` -routes/ (Blueprint) → controllers/ → services/ → repositories/ - ↓ - 职责清晰的请求处理层 -``` - ---- - -## 4. Controllers 层技术设计 - -### 4.1 目录结构 - -``` -outlook_web/ -├── controllers/ -│ ├── __init__.py # 模块导出 -│ ├── accounts.py # 账号管理 Controller(20 个路由) -│ ├── emails.py # 邮件操作 Controller(4 个路由) -│ ├── groups.py # 分组管理 Controller(6 个路由) -│ ├── tags.py # 标签管理 Controller(4 个路由) -│ ├── temp_emails.py # 临时邮箱 Controller(3 个路由) -│ ├── oauth.py # OAuth 授权 Controller(2 个路由) -│ ├── settings.py # 系统设置 Controller(3 个路由) -│ ├── scheduler.py # 定时任务 Controller(1 个路由) -│ ├── system.py # 系统信息 Controller(3 个路由) -│ ├── audit.py # 审计日志 Controller(1 个路由) -│ └── pages.py # 页面路由 Controller(3 个路由) -``` - -### 4.2 Controller 职责边界 - -**必须做的事情:** -- ✅ 参数解析(从 request 中提取参数) -- ✅ 参数验证(基本验证,如非空检查) -- ✅ 鉴权检查(使用 @login_required 装饰器) -- ✅ 调用 services 或 repositories -- ✅ 响应封装(使用 jsonify) -- ✅ 错误处理(try-except,返回标准错误格式) - -**不应该做的事情:** -- ❌ 直接操作数据库(应调用 repositories) -- ❌ 复杂的业务逻辑(应调用 services) -- ❌ 直接调用第三方 API(应通过 services) -- ❌ 数据加密/解密(应使用 security 模块) -- ❌ 依赖 Flask 全局对象传递到下层(request, g, session 只在 controller 中使用) - -### 4.3 Controller 标准模式 - -#### 4.3.1 简单 CRUD Controller - -```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: - # 1. 调用 repository - groups = groups_repo.get_all_groups() - - # 2. 返回响应 - return jsonify(groups) - except Exception as e: - # 3. 错误处理 - return jsonify(build_error_payload(str(e))), 500 - -@login_required -def api_add_group(): - """添加分组""" - try: - # 1. 参数解析 - data = request.get_json() - name = data.get('name', '').strip() - - # 2. 参数验证 - if not name: - return jsonify(build_error_payload('分组名称不能为空')), 400 - - # 3. 调用 repository - group_id = groups_repo.create_group(name) - - # 4. 返回响应 - return jsonify({'id': group_id, 'name': name}) - except Exception as e: - # 5. 错误处理 - return jsonify(build_error_payload(str(e))), 500 -``` - -#### 4.3.2 复杂业务逻辑 Controller - -```python -# outlook_web/controllers/emails.py -from flask import request, jsonify -from outlook_web.security.auth import login_required -from outlook_web.services import graph as graph_service -from outlook_web.services import imap as imap_service -from outlook_web.repositories import accounts as accounts_repo -from outlook_web.errors import build_error_payload - -@login_required -def api_get_emails(email_addr: str): - """获取邮件列表(支持 Graph API 和 IMAP 回退)""" - try: - # 1. 获取账号信息 - account = accounts_repo.get_account_by_email(email_addr) - if not account: - return jsonify(build_error_payload('账号不存在')), 404 - - # 2. 参数解析 - folder = request.args.get('folder', 'inbox') - skip = request.args.get('skip', 0, type=int) - top = request.args.get('top', 20, type=int) - - # 3. 调用 service(业务逻辑在 service 中) - result = graph_service.get_emails( - client_id=account['client_id'], - refresh_token=account['refresh_token'], - folder=folder, - skip=skip, - top=top, - proxy_url=account.get('proxy_url') - ) - - # 4. 如果 Graph API 失败,回退到 IMAP(回退逻辑在 service 中) - if not result.get('success'): - result = imap_service.get_emails( - account=email_addr, - client_id=account['client_id'], - refresh_token=account['refresh_token'], - folder=folder, - skip=skip, - top=top - ) - - # 5. 返回响应 - return jsonify(result) - except Exception as e: - # 6. 错误处理 - return jsonify(build_error_payload(str(e))), 500 -``` - -### 4.4 是否需要 Controller 基类? - -**决策:不需要** - -**理由:** -1. Flask 本身不需要基类,使用装饰器和工具函数更简单 -2. Python 的鸭子类型不需要强制继承 -3. 统一的错误处理可以通过全局错误处理器实现 -4. 统一的响应格式可以通过工具函数实现 -5. 避免增加不必要的复杂度 - -**替代方案:** -- 使用装饰器(@login_required) -- 使用工具函数(build_error_payload, jsonify) -- 使用全局错误处理器(app.register_error_handler) - ---- - -## 5. Routes 层技术设计 - -### 5.1 Blueprint 创建模式 - -#### 5.1.1 迁移前(使用 impl 参数) - -```python -# outlook_web/routes/groups.py -from flask import Blueprint - -def create_blueprint(*, impl) -> Blueprint: - """ - 创建 groups Blueprint - - Args: - impl: 实现模块(legacy.py) - """ - bp = Blueprint("groups", __name__) - - # 路由注册(使用 impl 的函数) - bp.add_url_rule("/api/groups", view_func=impl.api_get_groups, methods=["GET"]) - bp.add_url_rule("/api/groups", view_func=impl.api_add_group, methods=["POST"]) - bp.add_url_rule("/api/groups/", view_func=impl.api_update_group, methods=["PUT"]) - bp.add_url_rule("/api/groups/", view_func=impl.api_delete_group, methods=["DELETE"]) - - return bp -``` - -#### 5.1.2 迁移后(直接导入 controller) - -```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__) - - # 路由注册(直接使用 controller 的函数) - bp.add_url_rule("/api/groups", view_func=groups_controller.api_get_groups, methods=["GET"]) - bp.add_url_rule("/api/groups", view_func=groups_controller.api_add_group, methods=["POST"]) - bp.add_url_rule("/api/groups/", view_func=groups_controller.api_update_group, methods=["PUT"]) - bp.add_url_rule("/api/groups/", view_func=groups_controller.api_delete_group, methods=["DELETE"]) - - return bp -``` - -### 5.2 必须保持不变 - -- ✅ URL 路径不变 -- ✅ HTTP 方法不变 -- ✅ 路由注册顺序不变 -- ✅ Blueprint 名称不变 -- ✅ URL 参数(如 ``)不变 - ---- - -(文档未完,待续...) -## 6. App.py 应用工厂设计 - -### 6.1 Blueprint 注册模式 - -#### 6.1.1 迁移前 - -```python -# outlook_web/app.py -from outlook_web import legacy -from outlook_web.routes import groups, tags, accounts, emails - -def create_app(): - app = Flask(__name__) - - # ... 其他初始化代码 ... - - # Blueprint 注册(使用 impl=legacy) - app.register_blueprint(groups.create_blueprint(impl=legacy)) - app.register_blueprint(tags.create_blueprint(impl=legacy)) - app.register_blueprint(accounts.create_blueprint(impl=legacy)) - app.register_blueprint(emails.create_blueprint(impl=legacy)) - - return app -``` - -#### 6.1.2 迁移后 - -```python -# outlook_web/app.py -from outlook_web.routes import groups, tags, accounts, emails - -def create_app(): - app = Flask(__name__) - - # ... 其他初始化代码 ... - - # Blueprint 注册(不再需要 impl 参数) - app.register_blueprint(groups.create_blueprint()) - app.register_blueprint(tags.create_blueprint()) - app.register_blueprint(accounts.create_blueprint()) - app.register_blueprint(emails.create_blueprint()) - - return app -``` - -### 6.2 必须保持不变 - -- ✅ Blueprint 注册顺序不变 -- ✅ 中间件注册不变(before_request, after_request) -- ✅ 错误处理器注册不变(register_error_handler) -- ✅ 应用配置不变(secret_key, session 配置等) - ---- - -## 7. 数据流设计 - -### 7.1 请求处理流程 - -``` -HTTP Request - ↓ -Flask WSGI - ↓ -Middleware: ensure_trace_id() - ↓ -Routes: Blueprint 路由匹配 - ↓ -Controller: - - 参数解析(request.args, request.get_json()) - - 参数验证(非空检查、类型检查) - - 鉴权检查(@login_required) - ↓ -Service/Repository: - - 业务逻辑处理 - - 数据库操作 - ↓ -Controller: - - 响应封装(jsonify) - - 错误处理(try-except) - ↓ -Middleware: attach_trace_id_and_normalize_errors() - ↓ -Flask WSGI - ↓ -HTTP Response -``` - -### 7.2 错误处理流程 - -``` -Exception (任何层抛出) - ↓ -Controller: try-except 捕获 - ↓ -build_error_payload(error_message) - ↓ -jsonify(error_payload) + HTTP 状态码 - ↓ -Middleware: attach_trace_id_and_normalize_errors() - ↓ -统一错误响应格式: -{ - "success": false, - "error": "错误信息", - "trace_id": "xxx-xxx-xxx" -} -``` - -### 7.3 数据流示例:获取分组列表 - -``` -1. HTTP GET /api/groups -2. Flask 路由匹配 → groups_controller.api_get_groups -3. Controller: - - @login_required 检查登录状态 - - 调用 groups_repo.get_all_groups() -4. Repository: - - 执行 SQL: SELECT * FROM groups - - 返回 List[Dict] -5. Controller: - - jsonify(groups) - - 返回 HTTP 200 -6. Middleware: - - 添加 trace_id 到响应头 -7. HTTP Response: - [ - {"id": 1, "name": "分组1"}, - {"id": 2, "name": "分组2"} - ] -``` - ---- - -## 8. 迁移策略技术设计 - -### 8.1 分阶段迁移技术方案 - -#### 8.1.1 阶段 1:基础模块(groups, tags, settings, system, audit, pages) - -**技术步骤:** - -1. **创建 Controller 文件** - ```bash - touch outlook_web/controllers/groups.py - ``` - -2. **从 legacy.py 提取函数** - - 找到 legacy.py 中的 `api_get_groups`, `api_add_group` 等函数 - - 复制到 `controllers/groups.py` - -3. **调整导入语句** - ```python - # 迁移前(在 legacy.py 中) - from outlook_web.repositories import groups as _groups_repo - - # 迁移后(在 controllers/groups.py 中) - from outlook_web.repositories import groups as groups_repo - ``` - -4. **更新 routes/groups.py** - ```python - # 迁移前 - def create_blueprint(*, impl) -> Blueprint: - bp.add_url_rule("/api/groups", view_func=impl.api_get_groups, methods=["GET"]) - - # 迁移后 - from outlook_web.controllers import groups as groups_controller - - def create_blueprint() -> Blueprint: - bp.add_url_rule("/api/groups", view_func=groups_controller.api_get_groups, methods=["GET"]) - ``` - -5. **更新 app.py** - ```python - # 迁移前 - app.register_blueprint(groups.create_blueprint(impl=legacy)) - - # 迁移后 - app.register_blueprint(groups.create_blueprint()) - ``` - -6. **运行测试** - ```bash - python -m unittest discover -s tests -v - ``` - -7. **手动验证** - ```bash - curl -X GET http://localhost:5000/api/groups - ``` - -8. **提交 Git** - ```bash - git add outlook_web/controllers/groups.py outlook_web/routes/groups.py outlook_web/app.py - git commit -m "feat: 迁移 groups 模块到 controllers 层" - ``` - -#### 8.1.2 阶段 2 和 3:重复相同步骤 - -对于 temp_emails, oauth, scheduler, emails, accounts 模块,重复上述步骤。 - -**注意事项:** -- emails 和 accounts 模块更复杂,需要更仔细的测试 -- 确保回退机制(Graph API → IMAP)仍然正常工作 -- 确保数据脱敏仍然正常工作 - -### 8.2 兼容性保证机制 - -#### 8.2.1 函数签名保持不变 - -```python -# 迁移前(legacy.py) -@login_required -def api_get_groups(): - """获取所有分组""" - # ... - -# 迁移后(controllers/groups.py) -@login_required -def api_get_groups(): - """获取所有分组""" - # ... 完全相同的签名 -``` - -#### 8.2.2 装饰器保持不变 - -```python -# 迁移前后都使用相同的装饰器 -@login_required -def api_xxx(): - pass -``` - -#### 8.2.3 错误处理保持不变 - -```python -# 迁移前后都使用相同的错误处理 -try: - # ... -except Exception as e: - return jsonify(build_error_payload(str(e))), 500 -``` - -#### 8.2.4 响应格式保持不变 - -```python -# 迁移前后都使用相同的响应格式 -return jsonify(data) # 成功响应 -return jsonify(build_error_payload(msg)), 400 # 错误响应 -``` - -### 8.3 回滚机制 - -#### 8.3.1 Git 回滚 - -```bash -# 查看提交历史 -git log --oneline - -# 回滚到指定提交 -git revert - -# 或者硬回滚(慎用) -git reset --hard -``` - -#### 8.3.2 临时回滚(保留 legacy.py) - -如果迁移后发现问题,可以临时恢复 `impl=legacy`: - -```python -# 临时回滚到 legacy -from outlook_web import legacy -app.register_blueprint(groups.create_blueprint(impl=legacy)) -``` - -**注意:** 这要求 routes/groups.py 仍然支持 `impl` 参数,所以在迁移完成前不要删除这个参数。 - -#### 8.3.3 分支策略 - -```bash -# 在独立分支进行迁移 -git checkout -b feature/migrate-groups-to-controllers - -# 迁移完成后合并 -git checkout main -git merge feature/migrate-groups-to-controllers - -# 如果出现问题,可以快速切换回 main 分支 -git checkout main -``` - ---- - -## 9. 依赖管理设计 - -### 9.1 避免循环依赖 - -#### 9.1.1 依赖方向规则 - -``` -routes → controllers → services → repositories → db -``` - -**禁止的依赖:** -- ❌ repositories → services -- ❌ services → controllers -- ❌ controllers → routes - -#### 9.1.2 循环依赖检测 - -如果出现循环依赖,Python 会在导入时报错: - -```python -ImportError: cannot import name 'xxx' from partially initialized module 'yyy' -``` - -**解决方案:** -1. 检查导入语句,确保依赖方向正确 -2. 如果必须反向引用,使用延迟导入(在函数内部导入) - -### 9.2 全局状态管理 - -#### 9.2.1 Flask 全局对象 - -Flask 提供的全局对象: -- `request` - 当前请求对象 -- `g` - 请求级别的全局变量 -- `session` - 会话对象 -- `current_app` - 当前应用对象 - -**使用规则:** -- ✅ 在 controllers 中使用 -- ❌ 不要传递到 services 或 repositories -- ❌ services 和 repositories 应该是纯函数,不依赖 Flask 全局对象 - ---- - -## 10. 测试策略设计 - -### 10.1 单元测试设计 - -使用 unittest.mock.patch Mock 依赖。 - -### 10.2 集成测试设计 - -测试完整的请求-响应流程。 - -### 10.3 回归测试设计 - -确保所有现有测试通过。 - ---- - -## 11. 性能优化设计 - -### 11.1 层级调用开销分析 - -**问题:** 增加 controllers 层会增加一层函数调用,是否会影响性能? - -**分析:** -- Python 函数调用开销:约 0.1-0.5 微秒 -- 数据库查询开销:约 1-10 毫秒 -- HTTP 请求开销:约 10-100 毫秒 - -**结论:** 函数调用开销可以忽略不计(< 0.1%) - -### 11.2 导入开销分析 - -**问题:** 每个 controller 都要导入 services 和 repositories,是否会影响启动时间? - -**分析:** -- Python 的导入是缓存的,只在第一次导入时有开销 -- 模块导入开销:约 1-10 毫秒 -- 应用启动时间:约 1-2 秒 - -**结论:** 导入开销可以忽略不计(< 1%) - -### 11.3 内存开销分析 - -**问题:** 增加模块会增加内存使用吗? - -**分析:** -- 每个模块的内存开销:约 10-100 KB -- 应用总内存使用:约 50-100 MB - -**结论:** 内存开销可以忽略不计(< 0.1%) - -### 11.4 性能优化建议 - -**不需要特殊优化:** -- 层级调用开销极小 -- 导入开销极小 -- 内存开销极小 - -**如果确实需要优化:** -- 使用数据库连接池(已有) -- 使用缓存(如果需要) -- 优化 SQL 查询(已有索引) - ---- - -## 12. 错误处理设计 - -### 12.1 统一错误格式 - -所有 API 错误响应使用统一格式: - -```json -{ - "success": false, - "error": "错误信息", - "trace_id": "xxx-xxx-xxx" -} -``` - -### 12.2 错误处理流程 - -```python -@login_required -def api_xxx(): - try: - # 业务逻辑 - result = service.do_something() - return jsonify(result) - except Exception as e: - # 统一错误处理 - return jsonify(build_error_payload(str(e))), 500 -``` - -### 12.3 全局错误处理器 - -保持现有的全局错误处理器: - -```python -# outlook_web/app.py -app.register_error_handler(HTTPException, legacy.handle_http_exception) -app.register_error_handler(Exception, legacy.handle_exception) -``` - ---- - -## 13. 兼容性保证设计 - -### 13.1 API 契约不变 - -- ✅ URL 路径不变 -- ✅ HTTP 方法不变 -- ✅ 请求参数不变 -- ✅ 响应格式不变 -- ✅ 错误格式不变 -- ✅ HTTP 状态码不变 - -### 13.2 功能行为不变 - -- ✅ 鉴权机制不变(@login_required) -- ✅ 数据脱敏不变(client_id, refresh_token) -- ✅ 审计日志不变 -- ✅ CSRF 防护不变 -- ✅ 回退机制不变(Graph API → IMAP) -- ✅ trace_id 机制不变 - -### 13.3 部署方式不变 - -- ✅ Docker 启动命令不变 -- ✅ Gunicorn 启动命令不变 -- ✅ 环境变量配置不变 -- ✅ 数据库结构不变 - ---- - -## 14. 代码示例汇总 - -### 14.1 完整的 Controller 示例 - -```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 -def api_get_group(group_t): - """获取单个分组""" - try: - group = groups_repo.get_group_by_id(group_id) - if not group: - return jsonify(build_error_payload('分组不存在')), 404 - return jsonify(group) - except Exception as e: - return jsonify(build_error_payload(str(e))), 500 - -@login_required -def api_add_group(): - """添加分组""" - try: - data = request.get_json() - name = data.get('name', '').strip() - - if not name: - return jsonify(build_error_payload('分组名称不能为空')), 400 - - group_id = groups_repo.create_group(name) - return jsonify({'id': group_id, 'name': name}) - except Exception as e: - return jsonify(build_error_payload(str(e))), 500 - -@login_required -def api_update_group(group_id: int): - """更新分组""" - try: - data = request.get_json() - name = data.get('name', '').strip() - - if not name: - return jsonify(build_error_payload('分组名称不能为空')), 400 - - success = groups_repo.update_group(group_id, name) - if not success: - urn jsonify(build_error_payload('分组不存在')), 404 - - return jsonify({'id': group_id, 'name': name}) - except Exception as e: - return jsonify(build_error_payload(str(e))), 500 - -@login_required -def api_delete_group(group_id: int): - """删除分组""" - try: - success = groups_repo.delete_group(group_id) - if not success: - return jsonify(build_error_payload('分组不存在')), 404 - - return jsonify({'message': '删除成功'}) - except Exception as e: - return jsonify(build_error_payload(str(e))), 500 -``` - -### 14.2 完整的 Routes 示例 - -```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/", - view_func=groups_controller.api_get_group, - methods=["GET"]) - - bp.add_url_rule("/api/groups", - view_func=groups_controller.api_add_group, - methods=["POST"]) - - bp.add_url_rule("/api/groups/", - view_func=groups_controller.api_update_group, - methods=["PUT"]) - - bp.add_url_rule("/api/groups/", - view_func=groups_controller.api_delete_group, - methods=["DELETE"]) - - return bp -``` - -### 14.3 完整的测试示例 - -```python -# tests/test_controllers_groups.py -import unittest -from unittest.mock import patch, MagicMock -from flask import Flask -from outlook_web.controllers import groups as groups_controller - -class TestGroupsController(unittest.TestCase): - - def setUp(self): - self.app = Flask(__name__) - self.app.config['TESTING'] = True - self.client = self.app.test_client() - - @self.app.route('/api/groups') - def get_groups(): - return groups_controller.api_get_groups() - - @patch('outlook_web.controllers.groups.session') - @patch('outlook_web.controllers.groups.groups_repo') - def test_api_get_groups_success(self, mock_repo, mock_session): - mock_session.get.return_value = True - mock_repo.get_all_groups.return_value = [ - {'id': 1, 'name': '分组1'}, - {'id': 2, 'name': '分组2'} - ] - - response = self.client.get('/api/groups') - - self.assertEqual(response.status_code, 200) - data = response.get_json() - self.assertEqual(len(data), 2) - self.assertEqual(data[0]['name'], '分组1') - -if __name__ == '__main__': - unittest.main() -``` - ---- - -## 15. 实施检查清单 - -### 15.1 迁移前检查 - -- [ ] 阅读 PRD 和 FD 文档 -- [ ] 阅读本 TDD 文档 -- [ ] 理解现有架构 -- [ ] 运行所有测试,确保通过 -- [ ] 创建 Git 分支 - -### 15.2 迁移中检查 - -- [ ] 创建 controllers 目录 -- [ ] 创建 controller 文件 -- [ ] 从 legacy.py 提取函数 -- [ ] 调整导入语句 -- [ ] 更新 routes 文件 -- [ ] 更新 app.py -- [ ] 运行测试 -- [ ] 手动验证 -- [ ] 提交 Git - -### 15.3 迁移后检查 - -- [ ] 所有测试通过 -- [ ] 所有功能正常 -- [ ] 性能无明显下降 -- [ ] 文档已更新 -- [ ] 代码审查通过 - ---- - -## 16. 总结 - -### 16.1 技术要点 - -1. **四层架构**:routes → controllers → services → repositories -2. **职责清晰**:每层只做一件事 -3. **依赖单向**:避免循环依赖 -4. **兼容性保证**:API 契约不变 -5. **分阶段迁移**:可回滚、可验证 -6. **测试覆盖**:单元测试 + 集成测试 - -### 16.2 关键决策 - -1. **不使用 Controller 基类**:使用装饰器和工具函数更简单 -2. **不引入 DI 框架**:直接导入即可,测试时使用 mock -3. **保留 legacy.py**:直到所有模块迁移完成 -4. **分阶段迁移**:从简单到复杂,降低风险 - -##益 - -1. **代码可维护性提升**:职责清晰,易于理解 -2. **测试覆盖率提升**:易于编写单元测试 -3. **代码复用性提升**:services 可以被多个 controllers 调用 -4. **团队协作效率提升**:模块化便于并行开发 - ---- - -**文档版本:** v1.0 -**最后更新:** 2026-02-24 -**维护者:** 开发团队 diff --git a/docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md b/docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md deleted file mode 100644 index 18357d2..0000000 --- a/docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md +++ /dev/null @@ -1,331 +0,0 @@ -# Outlook 邮件管理工具|前后端拆分与模块化 TDD(技术设计细节) - -- 文档状态:草案 -- 版本:V0.1 -- 日期:2026-02-23 -- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-前后端拆分与模块化PRD.md` -- 对齐 FD:`docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md` -- 对齐 TODO:`docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md` -- 关联 CR:`docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md` - ---- - -## 1. 文档目的 - -本 TDD 用于描述“为满足 PRD/FD,前后端拆分与模块化需要采用的技术设计细节”。 -重点回答: - -- 拆成哪些模块、各模块职责边界是什么、依赖方向如何约束 -- 如何保证 **URL/响应契约/错误结构/trace_id/调度器行为** 不回归 -- 如何渐进式迁移(每阶段可回滚、可验收、可测试) - ---- - -## 2. 设计原则与硬约束(必须满足) - -### 2.1 行为兼容性(对用户无感) - -- 保持现有 API 路径/方法/参数语义/响应结构不变。 -- 保持统一错误结构、`trace_id` 透传、默认脱敏策略不变。 -- 保持登录/会话/CSRF 行为不变。 -- 保持定时刷新“推荐运行方式/自启动策略/可验证证据接口”不变。 - -### 2.2 渐进式拆分(可回滚、可验证) - -- 任何阶段都必须可运行且可回归:至少通过 `python -m unittest discover -s tests -v` 与关键接口抽样验收。 -- 允许短期存在兼容层(旧入口 facade / re-export),但每阶段要有明确的“可删除点”。 - -### 2.3 不引入新复杂度 - -- 前端不引入 React/Vue 等框架,不引入 Webpack/Vite 等构建链;以 **ES Module + 静态资源引用** 为主。 -- 后端不引入新框架迁移(仍为 Flask),数据库仍为 SQLite,迁移策略保持现状。 - ---- - -## 3. 总体架构设计 - -### 3.1 后端分层(建议) - -采用典型分层与关注点分离: - -- Presentation(Routes):参数解析/鉴权/调用 service/返回 JSON -- Service(业务编排):回退策略/流程编排/审计触发/错误聚合 -- Repository(数据访问):SQL 与数据映射,不承载业务规则 -- Infrastructure(基础设施):配置、DB、加密、错误模型、调度器、第三方客户端 - -依赖方向约束(必须遵守): - -`routes -> services -> repositories -> db` -`routes/services` 可以依赖 `errors/security/audit/utils`,但 **repositories 不能依赖 routes**。 - -### 3.2 前端模块化(建议) - -- `api` 层:统一 `fetch` 封装、错误结构处理、trace_id 展示、CSRF/header 处理 -- `ui` 层:modal/toast/loading 等通用组件 -- `features` 层:groups/accounts/tags/emails/refresh/export/settings/audit/temp_emails 等业务模块 -- `state` 层:当前分组/账号/分页/缓存等前端状态(可用简单对象,避免引入状态管理框架) - ---- - -## 4. 后端技术设计细节 - -### 4.1 目标目录结构(建议) - -新增后端包(示例命名,可在实现阶段微调): - -``` -outlook_web/ - __init__.py - app.py # create_app + 装配 - config.py # 环境变量读取/默认值/运行模式 - db.py # 连接管理、init_db、迁移/索引 - errors.py # error payload、trace_id、脱敏、全局异常处理 - audit.py # log_audit + 审计查询 - security/ - __init__.py - crypto.py # SECRET_KEY 派生、encrypt/decrypt、hash_password - auth.py # login_required、session 工具、ip 获取 - csrf.py # CSRF 集成与降级 - repositories/ - accounts.py - groups.py - tags.py - temp_emails.py - services/ - graph.py - imap.py - email_delete.py # 删除邮件回退聚合(可选单独文件) - refresh.py # token 刷新编排(可选) - gptmail.py - scheduler/ - scheduler.py # APScheduler 初始化/装配/心跳 - locks.py # distributed_locks 抽象 - runs.py # refresh_runs 记录/查询 - routes/ - system.py - scheduler.py - groups.py - accounts.py - tags.py - emails.py - oauth.py - settings.py - temp_emails.py -``` - -约束: - -- `outlook_web/app.py` 只负责装配与注册,不承载大段业务代码。 -- `init_db()` 与 schema 迁移逻辑集中在 `db.py`,避免散落。 - -### 4.2 应用装配(create_app)与入口兼容 - -#### 4.2.1 目标 - -- 支持 `create_app()`(便于测试与复用)。 -- 同时保持部署入口兼容:现有 `gunicorn ... web_outlook_app:app` 不需要立即改动。 - -#### 4.2.2 设计 - -- `outlook_web/app.py`: - - `create_app()`:创建 Flask app、加载 config、注册 blueprint、注册 error handler、初始化 db/scheduler(按环境变量开关)。 -- `web_outlook_app.py` 在迁移阶段变为 facade: - - `from outlook_web.app import create_app` - - `app = create_app()` - - 继续保留必要的兼容导出(避免影响 tests/与外部启动方式) - -**禁止**:在模块 import 时启动后台线程(调度器必须在 `create_app()` 内按开关启动)。 - -### 4.3 配置与环境变量 - -`outlook_web/config.py` 负责集中读取并提供类型安全的配置: - -- `SECRET_KEY`(必填,且变更会影响历史加密数据) -- `DATABASE_PATH`(默认 `data/outlook_accounts.db`) -- `LOGIN_PASSWORD`(初始登录密码) -- `SCHEDULER_AUTOSTART`(是否在 WSGI 导入场景自启动调度器) -- 其它现有设置项保持原名与语义 - -### 4.4 错误模型、trace_id、脱敏(统一入口) - -`outlook_web/errors.py`: - -- `generate_trace_id()`:若请求头带 `X-Trace-Id` 则复用,否则生成。 -- `build_error_payload(code, message, type, status, details)`:默认对 message/details 脱敏。 -- 全局异常处理: - - `HTTPException` → `HTTP_ERROR` - - 未捕获异常 → `UNCAUGHT_EXCEPTION` -- `after_request`: - - 统一补 `X-Trace-Id` - - legacy `{success:false,error:"..."}` 归一化为结构化 error(保持现有兼容策略) - -### 4.5 数据库与迁移(init_db) - -`outlook_web/db.py`: - -- `create_sqlite_connection()`:统一设置 row_factory、busy_timeout、foreign_keys。 -- `get_db()`:基于 `flask.g` 的连接生命周期管理。 -- `init_db()`: - - 幂等建表、缺列补齐、索引创建、schema 版本写入与迁移记录(保持现有逻辑与表结构) - - 并发迁移使用 `BEGIN IMMEDIATE`(保持一致性) - -### 4.6 Repository(SQL)设计 - -Repository 层只做“数据访问与映射”,以函数形式即可(不强制类): - -- `repositories/accounts.py` - - `get_account_by_id(db, account_id)` / `add_account(db, ...)` / `update_account(db, ...)` / `search_accounts(db, q)` 等 -- 统一约束: - - 不返回敏感字段明文(若业务需要解密,放在 service 层做) - - 所有 SQL 参数化,禁止字符串拼接注入 - -### 4.7 Service(业务编排)设计 - -Service 层负责: - -- Graph/IMAP/GPTMail 调用与错误聚合 -- Token 刷新/删除邮件等“多方式回退策略” -- 审计触发(不记录敏感明文) - -建议把“聚合错误”的结构保持与现有一致(前端依赖 `error.details` 展示)。 - -### 4.8 Routes(Blueprint)设计 - -每个路由文件暴露一个 blueprint: - -- `bp = Blueprint("groups", __name__)` -- 使用 `@login_required` 等装饰器保持一致 -- 注册:`app.register_blueprint(groups.bp)`(不使用 `url_prefix` 或用空前缀,确保 URL 不变) - -### 4.9 调度器(Scheduler)与 import-time 副作用控制 - -目标:避免“import 就启动线程”,但保持既有运行方式下的定时刷新能力。 - -策略: - -- `create_app()` 内: - - 先 `init_db()`,再根据 `SCHEDULER_AUTOSTART`(或 TESTING 环境)决定是否 `init_scheduler()` -- 测试环境默认禁用自启动(现有 `tests/_import_app.py` 已采用环境变量方式) - ---- - -## 5. 前端技术设计细节 - -### 5.1 静态资源拆分与引用 - -新增目录: - -``` -static/ - js/ - css/ -``` - -`templates/index.html`: - -- 移除大段内联脚本,改为引用: - - `` -- CSS 迁移到 `/static/css/main.css` - -### 5.2 API 封装(api.js) - -提供统一方法: - -- `apiGet(path, opts)` / `apiPost(path, body, opts)` / `apiDelete(...)` -- 统一处理: - - JSON parse - - `success=false` 的结构化 error 展示(复用现有 `handleApiError()` 语义) - - `X-Trace-Id` 的获取与展示(用于排障复制) - - CSRF token/header(保持现有策略) - -### 5.3 Feature 模块拆分 - -每个 feature 文件负责: - -- UI 事件绑定(按钮/表单/表格交互) -- 调用 `api.js` 与更新 `state` -- 只依赖 `ui/*` 与 `api.js/state.js`,避免 feature 互相调用导致耦合 - -### 5.4 渐进迁移策略(避免一次性大改) - -- 第一步允许把原来所有脚本“整体搬到 main.js”以保持行为一致; -- 第二步再从 main.js 中按 feature 拆分导出函数/类; -- 第三步再考虑把 HTML 结构拆成 Jinja partials(减少冲突)。 - ---- - -## 6. 迁移阶段与验收(每阶段必做) - -> 每个阶段都必须:单测通过 + 抽样接口验收通过 + 可回滚。 - -### 阶段 0:基线冻结 - -- 锁定现有接口契约与关键行为(以现有 tests + QA 文档为证据)。 - -### 阶段 1:后端骨架 + 兼容入口 - -- 引入 `outlook_web/create_app()`,`web_outlook_app.py` 变为 facade 并继续导出 `app`。 -- 验收: - - Docker/Gunicorn 启动命令不变 - - 单测全过 - -### 阶段 2:拆基础设施(低风险) - -- 拆 `config/errors/security/crypto/db/audit`,旧文件只转发调用。 -- 增补单测:错误结构/trace_id/脱敏不回归。 - -### 阶段 3:拆 repositories + services - -- SQL 下沉 repositories,回退策略与业务编排上移 services。 -- 增补单测:纯逻辑单测(不依赖 Flask app)。 - -### 阶段 4:拆 routes(Blueprint 化) - -- 分模块路由文件并注册蓝图,URL 不变。 -- 增补测试:关键路由存在性与响应结构抽样。 - -### 阶段 5:前端静态资源拆分 - -- 先整体迁移脚本到 `static/js/main.js`,再按 feature 拆分。 -- 抽样回归:登录/分组/账号/导出/审计/临时邮箱/设置等关键路径。 - ---- - -## 7. 测试策略(TDD/回归保障) - -### 7.1 单元测试(优先补 service/repository) - -- repository:SQL 结果映射、边界条件(空/不存在/重复) -- service:回退聚合、错误结构、脱敏、审计 details 生成 - -### 7.2 集成测试(保契约) - -- 使用 Flask `test_client` 验证关键接口: - - `/healthz` - - `/api/system/health` - - `/api/system/upgrade-status` - - `/api/audit-logs` - - `/api/scheduler/status` -- 对“拆分后的装配”增加 smoke test(蓝图是否注册、静态资源是否可访问)。 - -### 7.3 变更门禁 - -- CI 必跑:`python -m unittest discover -s tests -v` - ---- - -## 8. 风险与缓解 - -- 循环依赖风险:通过分层约束与“只从上往下依赖”解决;必要时引入 `types.py` 放共享数据结构。 -- import-time 副作用:调度器启动必须延迟到 `create_app()`,测试环境必须禁用自启动。 -- 契约漂移:每阶段用“抽样契约测试 + QA 清单”兜底;禁止随手改接口字段名。 -- 前端全局变量依赖:第一阶段允许保留 `window.*`,后续再逐步收敛成模块内部状态。 - ---- - -## 9. 交付验收(技术侧) - -- FD 2.x 清单项全部满足。 -- 单测与 CI 通过。 -- 关键抽样接口契约不变。 -- 迁移过程中每阶段的“回滚点”清晰可执行。 diff --git a/docs/TDD/Outlook邮件管理工具-批量操作与自动化增强TDD.md b/docs/TDD/Outlook邮件管理工具-批量操作与自动化增强TDD.md deleted file mode 100644 index 14a5af0..0000000 --- a/docs/TDD/Outlook邮件管理工具-批量操作与自动化增强TDD.md +++ /dev/null @@ -1,873 +0,0 @@ -# Outlook 邮件管理工具|批量操作与自动化增强 TDD(技术设计细节) - -- 文档状态:草案 -- 版本:V1.0 -- 日期:2026-02-24 -- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-批量操作与自动化增强PRD.md` -- 对齐 FD:`docs/FD/Outlook邮件管理工具-批量操作与自动化增强FD.md` - ---- - -## 1. 文档目的 - -本 TDD 用于描述"为满足 PRD/FD,批量操作与自动化增强需要采用的技术设计细节"。 - -重点回答: - -- 验证码提取算法的具体实现方案 -- 全选功能的性能优化策略(支持 1000+ 邮箱) -- 轮询机制的技术实现(定时器、状态管理、新邮件检测) -- 前端剪贴板 API 的使用与兼容性处理 -- 数据库 schema 变更与配置管理 -- API 接口设计与错误处理 - ---- - -## 2. 设计原则与硬约束(必须满足) - -### 2.1 行为兼容性(对用户无感) - -- 保持现有 API 路径/方法/参数语义/响应结构不变 -- 保持统一错误结构、`trace_id` 透传、默认脱敏策略不变 -- 保持登录/会话/CSRF 行为不变 -- 新增功能不影响现有功能的正常使用 - -### 2.2 性能要求 - -- 全选操作在 100ms 内完成(1000 个邮箱) -- 验证码提取 API 响应时间 < 2 秒 -- 轮询操作不阻塞主线程,不影响页面交互 - -### 2.3 可靠性要求 - -- 验证码提取成功率 > 90%(基于常见邮件格式) -- 轮询失败时自动停止并提示用户 -- 剪贴板操作失败时有降级方案 - -### 2.4 安全要求 - -- 验证码提取 API 需要登录验证 -- 复制内容不包含敏感信息 -- 轮询频率有合理限制 - ---- - -## 3. 总体架构设计 - -### 3.1 功能模块划分 - -``` -批量操作与自动化增强 -├── 功能1:全选功能(纯前端) -│ ├── UI 组件(全选复选框) -│ ├── 状态管理(selectedAccounts Set) -│ └── 性能优化(批量 DOM 更新) -│ -├── 功能2:邮件信息快速复制(前后端) -│ ├── 后端 -│ │ ├── API 接口(/api/emails//extract-verification) -│ │ ├── 验证码提取服务(智能识别 + 保底规则) -│ │ └── 链接提取服务 -│ └── 前端 -│ ├── 复制按钮组件 -│ ├── 剪贴板 API 调用 -│ └── 反馈提示 -│ -└── 功能3:自动轮询与通知(前后端) - ├── 后端 - │ ├── 数据库配置项(settings 表) - │ └── 设置 API 更新 - └── 前端 - ├── 设置界面 - ├── 轮询逻辑(定时器) - ├── 新邮件检测 - └── 通知组件 -``` - -### 3.2 技术栈 - -- **后端**:Flask + SQLite + Python 正则表达式 -- **前端**:原生 JavaScript + Clipboard API + CSS3 动画 -- **数据存储**:SQLite settings 表(key-value 结构) - ---- - -## 4. 后端技术设计细节 - -### 4.1 验证码提取服务 - -#### 4.1.1 模块位置 - -``` -outlook_web/services/verification_extractor.py -``` - -#### 4.1.2 核心算法设计 - -**智能识别阶段:** - -```python -# 关键词列表(支持中英文) -VERIFICATION_KEYWORDS = [ - "验证码", "code", "验证", "verification", - "OTP", "动态码", "校验码", "verify code", - "confirmation code", "security code" -] - -# 验证码模式(4-8位数字或字母) -VERIFICATION_PATTERN = r'\b[A-Z0-9]{4,8}\b' - -def smart_extract_verification_code(email_content): - """ - 智能提取验证码 - - 算法: - 1. 遍历关键词列表 - 2. 在邮件内容中查找关键词位置 - 3. 在关键词前后 50 个字符范围内搜索验证码模式 - 4. 返回第一个匹配的验证码 - """ - content_lower = email_content.lower() - - for keyword in VERIFICATION_KEYWORDS: - keyword_lower = keyword.lower() - pos = content_lower.find(keyword_lower) - - if pos != -1: - # 提取关键词前后 50 个字符 - start = max(0, pos - 50) - end = min(len(email_content), pos + len(keyword) + 50) - context = email_content[start:end] - - # 在上下文中搜索验证码 - matches = re.findall(VERIFICATION_PATTERN, context, re.IGNORECASE) - if matches: - # 过滤掉纯字母的匹配(验证码通常包含数字) - for match in matches: - if any(c.isdigit() for c in match): - return match - - return None -``` - -**保底提取阶段:** - -```python -def fallback_extract_verification_code(email_content): - """ - 保底提取验证码 - - 算法: - 1. 提取所有 4-8 位的数字/字母组合 - 2. 过滤掉常见的非验证码模式(日期、时间等) - 3. 返回第一个匹配项 - """ - # 提取所有可能的验证码 - matches = re.findall(VERIFICATION_PATTERN, email_content, re.IGNORECASE) - - # 过滤规则 - filtered = [] - for match in matches: - # 必须包含至少一个数字 - if not any(c.isdigit() for c in match): - continue - - # 排除纯数字且长度为 4 的(可能是年份) - if match.isdigit() and len(match) == 4: - year = int(match) - if 1900 <= year <= 2100: - continue - - # 排除常见的时间格式(如 1234 可能是 12:34) - if match.isdigit() and len(match) == 4: - hour = int(match[:2]) - minute = int(match[2:]) - if 0 <= hour <= 23 and 0 <= minute <= 59: - continue - - filtered.append(match) - - return filtered[0] if filtered else None -``` - -**链接提取:** - -```python -def extract_links(email_content): - """ - 提取所有 HTTP/HTTPS 链接 - - 算法: - 1. 使用正则表达式提取所有链接 - 2. 去重并保持顺序 - """ - # 链接正则表达式 - LINK_PATTERN = r'https?://[^\s<>"{}|\\^`\[\]]+' - - matches = re.findall(LINK_PATTERN, email_content, re.IGNORECASE) - - # 去重并保持顺序 - seen = set() - unique_links = [] - for link in matches: - if link not in seen: - seen.add(link) - unique_links.append(link) - - return unique_links -``` - -#### 4.1.3 完整提取流程 - -```python -def extract_verification_info(email_address): - """ - 提取验证信息的完整流程 - - 返回: - { - "verification_code": "123456", - "links": ["https://example.com/verify"], - "formatted": "123456 https://example.com/verify" - } - """ - # 1. 获取最新邮件(同时从收件箱和垃圾邮件获取) - latest_email = get_latest_email(email_address) - - if not latest_email: - raise ValueError("未找到邮件") - - # 2. 提取邮件内容(HTML 转纯文本) - email_content = extract_email_text(latest_email) - - # 3. 提取验证码(智能识别 + 保底) - verification_code = smart_extract_verification_code(email_content) - if not verification_code: - verification_code = fallback_extract_verification_code(email_content) - - # 4. 提取链接 - links = extract_links(email_content) - - # 5. 格式化输出 - parts = [] - if verification_code: - parts.append(verification_code) - parts.extend(links) - - formatted = " ".join(parts) if parts else None - - if not formatted: - raise ValueError("未找到验证信息") - - return { - "verification_code": verification_code, - "links": links, - "formatted": formatted - } -``` - -### 4.2 API 接口设计 - -#### 4.2.1 验证码提取接口 - -**路由定义:** - -```python -# outlook_web/routes/emails.py -bp.add_url_rule( - "/api/emails//extract-verification", - view_func=impl.api_extract_verification, - methods=["GET"] -) -``` - -**实现逻辑:** - -```python -# outlook_web/legacy.py 或新的 view 层 -@login_required -def api_extract_verification(email): - """ - 提取验证码和链接接口 - - 参数: - - email: 邮箱地址(路径参数) - - 返回: - 成功: - { - "success": true, - "data": { - "verification_code": "123456", - "links": ["https://example.com/verify"], - "formatted": "123456 https://example.com/verify" - }, - "message": "提取成功" - } - - 失败: - { - "success": false, - "error": "未找到验证信息", - "trace_id": "xxx" - } - """ - try: - # 调用验证码提取服务 - result = extract_verification_info(email) - - return jsonify({ - "success": True, - "data": result, - "message": "提取成功" - }) - - except ValueError as e: - # 未找到验证信息 - return jsonify({ - "success": False, - "error": str(e), - "trace_id": generate_trace_id() - }), 404 - - except Exception as e: - # 其他错误 - return jsonify({ - "success": False, - "error": "提取失败", - "trace_id": generate_trace_id() - }), 500 -``` - -**错误处理策略:** - -1. **邮箱不存在**:返回 404,错误信息:"邮箱不存在" -2. **未找到邮件**:返回 404,错误信息:"未找到邮件" -3. **未找到验证信息**:返回 404,错误信息:"未找到验证信息" -4. **Token 过期**:返回 401,错误信息:"Token 过期,请刷新" -5. **网络错误**:返回 500,错误信息:"网络错误,请重试" - -### 4.3 数据库设计 - -#### 4.3.1 settings 表新增配置项 - -**配置项定义:** - -| Key | Value Type | Default | Description | -|-----|------------|---------|-------------| -| `enable_auto_polling` | boolean | `false` | 是否启用自动轮询 | -| `polling_interval` | integer | `10` | 轮询间隔(秒) | -| `polling_count` | integer | `5` | 轮询次数 | - -**初始化逻辑:** - -```python -# outlook_web/db.py - 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") -) -``` - -**数据类型转换:** - -```python -# 读取配置时的类型转换 -def get_polling_settings(db): - """获取轮询配置""" - settings = {} - - # enable_auto_polling: string -> boolean - row = db.execute( - "SELECT value FROM settings WHERE key = ?", - ("enable_auto_polling",) - ).fetchone() - settings["enable_auto_polling"] = row["value"].lower() == "true" if row else False - - # polling_interval: string -> integer - row = db.execute( - "SELECT value FROM settings WHERE key = ?", - ("polling_interval",) - ).fetchone() - settings["polling_interval"] = int(row["value"]) if row else 10 - - # polling_count: string -> integer - row = db.execute( - "SELECT value FROM settings WHERE key = ?", - ("polling_count",) - ).fetchone() - settings["polling_count"] = int(row["value"]) if row else 5 - - return settings -``` - -#### 4.3.2 设置 API 更新 - -**GET /api/settings 响应更新:** - -```python -# 在现有响应中添加轮询配置 -{ - "success": true, - "settings": { - "login_password": "***", - "gptmail_api_key": "***", - "enable_auto_polling": false, - "polling_interval": 10, - "polling_count": 5, - ... - } -} -``` - -**PUT /api/settings 请求处理:** - -```python -@login_required -def api_update_settings(): - """更新设置""" - data = request.get_json() - - # 处理轮询配置 - if "enable_auto_polling" in data: - value = "true" if data["enable_auto_polling"] else "false" - db.execute( - "UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?", - (value, "enable_auto_polling") - ) - - if "polling_interval" in data: - # 验证范围:5-300 秒 - interval = int(data["polling_interval"]) - if not (5 <= interval <= 300): - return jsonify({ - "success": False, - "error": "轮询间隔必须在 5-300 秒之间" - }), 400 - - db.execute( - "UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?", - (str(interval), "polling_interval") - ) - - if "polling_count" in data: - # 验证范围:1-100 次 - count = int(data["polling_count"]) - if not (1 <= count <= 100): - return jsonify({ - "success": False, - "error": "轮询次数必须在 1-100 次之间" - }), 400 - - db.execute( - "UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?", - (str(count), "polling_count") - ) - - db.commit() - - return jsonify({ - "success": True, - "message": "设置已保存" - }) -``` - -### 4.4 邮件获取辅助函数 - -#### 4.4.1 获取最新邮件 - -```python -def get_latest_email(email_address): - """ - 获取邮箱的最新邮件 - - 策略: - 1. 同时从收件箱和垃圾邮件文件夹获取邮件 - 2. 按时间排序,取最新的一封 - 3. 使用现有的邮件获取 API(Graph API / IMAP) - """ - from outlook_web.services.graph import get_emails as get_emails_graph - from outlook_web.services.imap import get_emails as get_emails_imap - - # 获取账号信息 - db = get_db() - account = db.execute( - "SELECT * FROM accounts WHERE email = ?", - (email_address,) - ).fetchone() - - if not account: - raise ValueError("邮箱不存在") - - # 尝试获取邮件(使用现有的回退策略) - emails = [] - - # 从收件箱获取 - try: - inbox_emails = get_emails_graph( - account, folder="inbox", skip=0, top=1 - ) - emails.extend(inbox_emails) - except Exception: - pass - - # 从垃圾邮件获取 - try: - junk_emails = get_emails_graph( - account, folder="junkemail", skip=0, top=1 - ) - emails.extend(junk_emails) - except Exception: - pass - - if not emails: - raise ValueError("未找到邮件") - - # 按时间排序,取最新的 - emails.sort(key=lambda x: x.get("date", ""), reverse=True) - return emails[0] -``` - -#### 4.4.2 HTML 转纯文本 - -```python -import html -from html.parser import HTMLParser - -class HTMLTextExtractor(HTMLParser): - """HTML 转纯文本提取器""" - - def __init__(self): - super().__init__() - self.text = [] - - def handle_data(self, data): - self.text.append(data) - - def get_text(self): - return " ".join(self.text) - -def extract_email_text(email): - """ - 提取邮件纯文本内容 - - 优先级: - 1. body(纯文本) - 2. body_html(HTML 转纯文本) - 3. body_preview(预览文本) - """ - # 优先使用纯文本 - if email.get("body"): - return email["body"] - - # 其次使用 HTML 转纯文本 - if email.get("body_html"): - parser = HTMLTextExtractor() - parser.feed(email["body_html"]) - text = parser.get_text() - # 解码 HTML 实体 - text = html.unescape(text) - return text - - # 最后使用预览文本 - if email.get("body_preview"): - return email["body_preview"] - - return "" -``` - ---- - -## 5. 前端技术设计细节 - -### 5.1 全选功能 - -#### 5.1.1 数据结构设计 - -```javascript -// 全局变量(复用现有的) -let selectedAccounts = new Set(); // 存储选中的账号 ID - -// 全选状态枚举 -const SelectAllState = { - NONE: 'none', // 未选中 - PARTIAL: 'partial', // 部分选中(半选) - ALL: 'all' // 全选 -}; -``` - -#### 5.1.2 性能优化策略 - -**问题:** 当邮箱数量达到 1000 个时,全选操作可能导致页面卡顿 - -**优化方案:** - -1. **使用 Set 数据结构** - - 时间复杂度:O(1) 查找和插入 - - 空间复杂度:O(n) - -2. **批量 DOM 更新** - - 使用 requestAnimationFrame 优化渲染时机 - - 减少重排重绘次数 - -3. **事件委托** - - 使用事件委托减少事件监听器数量 - -```javascript -function selectAllAccounts() { - const accountItems = document.querySelectorAll('.account-item:not([style*="display: none"])'); - - // 批量添加到 Set(O(n) 时间复杂度) - accountItems.forEach(item => { - const accountId = item.dataset.accountId; - if (accountId) { - selectedAccounts.add(accountId); - } - }); - - // 使用 requestAnimationFrame 优化渲染 - requestAnimationFrame(() => { - updateAccountCheckboxes(); - updateSelectAllCheckbox(); - updateBatchActionBar(); - }); -} -``` - -### 5.2 剪贴板 API 兼容性处理 - -#### 5.2.1 浏览器兼容性 - -| 浏览器 | Clipboard API | execCommand | -|--------|---------------|-------------| -| Chrome 66+ | ✅ | ✅ | -| Firefox 63+ | ✅ | ✅ | -| Safari 13.1+ | ✅ | ✅ | -| Edge 79+ | ✅ | ✅ | -| IE 11 | ❌ | ✅ | - -#### 5.2.2 降级方案 - -```javascript -async function copyToClipboard(text) { - // 方法1:Clipboard API(现代浏览器) - if (navigator.clipboard && navigator.clipboard.writeText) { - try { - await navigator.clipboard.writeText(text); - return; - } catch (err) { - console.warn('Clipboard API 失败,尝试降级方案', err); - } - } - - // 方法2:execCommand(旧浏览器) - try { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.select(); - - const success = document.execCommand('copy'); - document.body.removeChild(textarea); - - if (!success) { - throw new Error('execCommand 失败'); - } - } catch (err) { - throw new Error('复制失败,请手动复制'); - } -} -``` - -### 5.3 轮询机制的技术实现 - -#### 5.3.1 定时器管理 - -```javascript -// 全局定时器变量 -let pollingTimer = null; - -function startPolling(email) { - // 先停止之前的定时器 - stopPolling(); - - // 创建新定时器 - pollingTimer = setInterval(() => { - pollForNewEmails(); - }, pollingInterval); -} - -function stopPolling() { - if (pollingTimer) { - clearInterval(pollingTimer); - pollingTimer = null; - } -} - -// 页面卸载时清理定时器 -window.addEventListener('beforeunload', () => { - stopPolling(); -}); -``` - -#### 5.3.2 新邮件检测算法 - -**算法复杂度分析:** - -- 时间复杂度:O(n),n 为新邮件数量 -- 空间复杂度:O(m),m 为旧邮件数量 - -```javascript -function detectNewEmails(oldEmailIds, newEmails) { - // 使用 Set 进行快速查找(O(1)) - const newEmailList = []; - - for (const email of newEmails) { - if (!oldEmailIds.has(email.id)) { - newEmailList.push(email); - } - } - - return newEmailList; -} -``` - ---- - -## 6. 测试策略 - -### 6.1 单元测试 - -#### 6.1.1 验证码提取算法测试 - -```python -# tests/test_verification_extractor.py - -import unittest -from outlook_web.services.verification_extractor import ( - smart_extract_verification_code, - fallback_extract_verification_code, - extract_links -) - -class TestVerificationExtractor(unittest.TestCase): - - def test_smart_extract_with_chinese_keyword(self): - """测试中文关键词识别""" - content = "您的验证码是 123456,请在10分钟内完成验证。" - result = smart_extract_verification_code(content) - self.assertEqual(result, "123456") - - def test_smart_extract_with_english_keyword(self): - """测试英文关键词识别""" - content = "Your verification code is ABC123." - result = smart_extract_verification_code(content) - self.assertEqual(result, "ABC123") - - def test_fallback_extract(self): - """测试保底提取""" - content = "Please use code XYZ789 to complete." - result = fallback_extract_verification_code(content) - self.assertEqual(result, "XYZ789") - - def test_extract_links(self): - """测试链接提取""" - content = "Click: https://example.com/verify?token=abc123" - result = extract_links(content) - self.assertEqual(len(result), 1) - self.assertIn("https://example.com/verify?token=abc123", result) -``` - ---- - -## 7. 部署与运维 - -### 7.1 数据库迁移 - -```python -# 在 init_db() 函数中添加 -def migrate_polling_settings(cursor): - """迁移轮询配置""" - 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") - ) -``` - -### 7.2 性能监控 - -```python -import time - -def api_extract_verification(email): - start_time = time.time() - - try: - result = extract_verification_info(email) - elapsed_time = time.time() - start_time - - if elapsed_time > 2.0: - print(f"⚠️ 验证码提取耗时过长: {elapsed_time:.2f}s") - - return jsonify({"success": True, "data": result}) - except Exception as e: - print(f"❌ 验证码提取失败: {str(e)}") - raise -``` - ---- - -## 8. 总结 - -### 8.1 技术亮点 - -1. **智能验证码识别算法**:关键词识别 + 保底规则,提取成功率 > 90% -2. **高性能全选**:使用 Set 数据结构 + 批量 DOM 更新,支持 1000+ 邮箱 -3. **可靠的轮询机制**:定时器管理 + 新邮件检测 + 自动停止 -4. **优雅的降级方案**:剪贴板 API 降级 + 错误处理 - -### 8.2 技术风险 - -1. **验证码识别准确率**:依赖邮件格式,可能存在误判 - - 缓解:提供保底规则 + 用户可手动查看邮件 -2. **浏览器兼容性**:Clipboard API 在旧浏览器不支持 - - 缓解:提供 execCommand 降级方案 -3. **轮询性能影响**:高频轮询可能影响页面性能 - - 缓解:设置合理的默认值 + 使用异步请求 - -### 8.3 后续优化方向 - -1. **验证码识别优化**: - - 支持更多语言的关键词 - - 支持图片验证码识别(OCR) - - 支持自定义提取规则 - -2. **性能优化**: - - 实现虚拟滚动(邮箱数量 > 1000) - - 使用 Web Worker 处理验证码提取 - - 使用 IndexedDB 缓存邮件数据 - -3. **功能增强**: - - 支持多邮箱同时轮询 - - 支持浏览器通知 - - 支持声音提示 diff --git a/docs/TDD/Outlook邮件管理工具-重构升级TDD.md b/docs/TDD/Outlook邮件管理工具-重构升级TDD.md deleted file mode 100644 index 13d9b84..0000000 --- a/docs/TDD/Outlook邮件管理工具-重构升级TDD.md +++ /dev/null @@ -1,143 +0,0 @@ -# Outlook 邮件管理工具|重构升级 TDD(技术交付清单,仅说明做什么) - -- 文档状态:草案 -- 版本:V1.0 -- 日期:2026-02-22 -- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-重构升级PRD.md` -- 对齐 FD:`docs/FD/Outlook邮件管理工具-重构升级FD.md` - ---- - -## 1. 文档目的 - -本 TDD 用于描述“为满足 PRD/FD,需要在工程与系统层面交付哪些能力与行为”。 -仅描述**需要完成什么功能/能力**与**可验收行为**,不描述实现过程与编码细节。 - ---- - -## 2. 总体约束(必须满足) - -- 保持现有对外使用方式与关键交互不被破坏(包含管理界面与核心接口行为)。 -- 升级后历史数据可继续使用,且升级过程可预期、可验证。 -- 关键链路(读取/刷新/删除/导出)在异常情况下返回结构稳定、可定位、且不泄露敏感信息。 -- 支持长期运行(7×24)与重启/升级后的稳定恢复。 - ---- - -## 3. 技术交付清单(必须完成) - -### 3.1 应用启动与运行形态 - -- 提供“Web 服务运行”能力:可启动并对外提供管理界面与接口服务。 -- 提供“后台刷新任务运行”能力:可在不依赖人工操作的情况下执行定时刷新。 -- 明确并落地“官方推荐运行方式”:保证在推荐方式下,定时刷新可用且可验证。 -- 提供运行状态可见性:管理员可以确认当前系统是否处于“可服务/可刷新”的正常状态。 - -### 3.2 定时刷新可用性与可验证 - -- 定时刷新具备以下能力: - - 启用/停用 - - 支持两类调度配置方式(按天数间隔、按表达式配置) - - 支持配置有效性校验与未来触发时间预览 -- 定时刷新运行必须可被验证: - - 可查看最近一次触发时间 - - 可查看触发来源标识(区分手动/全量/定时/重试) - - 可查看触发结果统计(总数/成功/失败) -- 避免重复触发与冲突: - - 同一周期内不应出现不可解释的重复执行 - - 支持在触发中对重复触发进行明确提示或拒绝 - -### 3.3 状态一致性与持久化(面向重启/扩容) - -- 对以下“影响安全或一致性”的状态提供一致性保障(重启/多实例下仍可用): - - 导出二次验证凭证与有效期管理 - - 登录失败计数、锁定状态与解锁时间 - - 定时刷新最近触发信息与执行中状态(如有) -- 以上状态需具备: - - 过期与清理策略 - - 诊断可见性(管理员能看到状态是否异常) - -### 3.4 错误模型与追踪标识统一 - -- 为所有接口返回提供统一的错误结构(至少包含错误编号、中文提示、追踪标识、可选的细节信息)。 -- 错误细节必须脱敏,禁止泄露令牌、密码、敏感凭据等内容。 -- 对“多方式回退”的失败信息必须能聚合展示,并可按方式分组呈现。 -- 未捕获异常必须被统一处理: - - 返回稳定的错误结构 - - 记录追踪标识,便于排查 - -### 3.5 安全能力交付(不降低现有安全基线) - -- 会话安全: - - 登录态具备合理的过期策略与安全属性 - - 在反向代理场景下来源标识(如 IP)获取行为可控且可解释 -- 输入与内容安全: - - 用户输入在展示与存储前必须符合“净化/长度限制/控制字符处理”等规则 - - 邮件 HTML 默认安全展示:禁止脚本执行与危险行为 - - “信任/原始内容”模式必须明确风险提示与默认关闭 -- 敏感数据保护: - - 敏感字段默认不在接口/界面中明文展示 - - 仅在导出且完成二次验证的场景允许输出敏感内容 - -### 3.6 数据兼容与升级保障 - -- 升级过程中支持对历史数据进行必要的兼容处理,确保升级后可继续使用: - - 账号、分组、标签、设置、刷新历史、审计记录等 -- 升级过程需具备可验证性: - - 可识别并提示升级/迁移是否完成 - - 失败时给出明确错误提示与恢复建议(至少包含追踪标识) -- 默认提供升级前备份提示与导出路径(面向管理员操作)。 - -### 3.7 刷新/导出/审计的行为一致性 - -- 刷新相关: - - 单个刷新、全量刷新、定时刷新、失败重试使用一致的结果记录口径 - - 刷新日志与统计对不同触发来源展示一致 -- 导出相关: - - 所有导出类型均必须经过二次验证 - - 导出文件命名规则一致且可识别(范围、时间) - - 导出行为必须产生审计记录 -- 审计相关: - - 敏感操作必须被记录(导出、删除、修改账号、修改分组、修改设置) - - 审计失败不影响主流程,但必须可被管理员发现 - -### 3.8 性能与容量保障 - -- 支持账号规模达到 1,000 的可用体验: - - 账号列表加载与筛选保持可用 - - 全量刷新过程持续输出进度,不出现长时间无反馈 -- 关键路径性能要求具备可验收口径: - - 明确“可接受时间”的定义与测试方式(验收用) - - 对慢查询/慢操作具备可定位信息(含追踪标识/操作类型) - -### 3.9 可观测与运维友好性 - -- 关键操作必须可被追踪与排障: - - 每次关键操作可关联追踪标识 - - 管理员可查看失败原因分类与建议动作 -- 提供基础健康状态能力: - - 能判断系统是否处于可用状态 - - 能判断定时刷新是否处于可用状态 - -### 3.10 自动化验证(回归保障) - -- 提供一组可重复执行的回归验证清单(面向发布): - - 登录/修改密码/退出 - - 分组增删改查与系统分组保护 - - 账号增删改查、批量导入、批量移动、状态启停 - - 标签增删与批量操作 - - 邮件三类文件夹读取、分页、详情、全屏、信任模式提示 - - 单个刷新、全量刷新、失败重试、定时刷新配置与运行证据 - - 单封/批量删除与二次确认 - - 导出三种模式与二次验证、审计记录生成 - - OAuth2 辅助流程与常见失败提示 - - 临时邮箱全流程与失败分类提示 -- 回归验证需覆盖“重启后再验证”的场景,确保状态与行为一致。 - ---- - -## 4. 交付验收(技术侧) - -- 上述 3.x 清单项逐条可验收,且能通过“运行证据/日志/界面信息/接口返回”进行确认。 -- 不新增或引入未在 PRD/FD 中声明的对外功能变更(除非补充 PRD/变更说明)。 - diff --git a/docs/TDD/TDD-可调整布局系统.md b/docs/TDD/TDD-可调整布局系统.md deleted file mode 100644 index 87d1d31..0000000 --- a/docs/TDD/TDD-可调整布局系统.md +++ /dev/null @@ -1,1163 +0,0 @@ -# TDD - 可调整布局系统 - -## 文档信息 - -- **文档编号**: TDD-00001 -- **创建日期**: 2026-02-26 -- **版本**: v1.0 -- **状态**: 草稿 -- **负责人**: 开发团队 -- **关联文档**: - - PRD-00001: PRD - 可调整布局系统 - - FD-00001: FD - 可调整布局系统 - -## 目录 - -1. [技术架构设计](#一技术架构设计) -2. [核心算法设计](#二核心算法设计) -3. [类与方法设计](#三类与方法设计) -4. [数据结构设计](#四数据结构设计) -5. [事件流设计](#五事件流设计) -6. [性能优化策略](#六性能优化策略) -7. [错误处理与边界情况](#七错误处理与边界情况) -8. [集成方案](#八集成方案) -9. [测试策略](#九测试策略) -10. [兼容性方案](#十兼容性方案) - -## 一、技术架构设计 - -### 1.1 整体架构 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ HTML Layer │ -│ ┌──────────┬──────────┬──────────┬────────────────────┐ │ -│ │ 分组面板 │ 账号面板 │ 邮件列表 │ 邮件详情面板 │ │ -│ │ [resizer]│ [resizer]│ [resizer]│ │ │ -│ └──────────┴──────────┴──────────┴────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ CSS Layer │ -│ • CSS Grid 布局系统 │ -│ • CSS 变量(--group-panel-width, --account-panel-width) │ -│ • CSS Transitions(折叠/展开动画) │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ JavaScript Layer │ -│ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ LayoutManager │◄──────►│ StateManager │ │ -│ │ • 拖动处理 │ │ • 状态保存 │ │ -│ │ • 折叠控制 │ │ • 状态加载 │ │ -│ │ • 键盘支持 │ │ • 数据验证 │ │ -│ │ • 窗口适配 │ │ • 版本迁移 │ │ -│ └──────────────────┘ └──────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ Storage Layer │ -│ localStorage: outlook_layout_state_${userId} │ -│ { │ -│ version: "1.1", │ -│ userId: "user123", │ -│ timestamp: 1709020800000, │ -│ panels: { groups: {...}, accounts: {...}, emails: {...} }│ -│ } │ -└─────────────────────────────────────────────────────────────┘ -``` - -### 1.2 模块依赖关系 - -``` -main.js - ├─► LayoutManager - │ ├─► StateManager (状态持久化) - │ ├─► DragHandler (拖动处理) - │ ├─► CollapseHandler (折叠处理) - │ ├─► KeyboardHandler (键盘支持) - │ └─► WindowResizeHandler (窗口适配) - │ - └─► StateManager - ├─► localStorage API - ├─► JSON.parse/stringify - └─► 数据验证逻辑 -``` - -### 1.3 技术选型 - -| 技术点 | 选型 | 理由 | -|--------|------|------| -| 布局系统 | CSS | 灵活、性能好、浏览器支持广泛 | -| 状态管理 | CSS 变量 + localStorage | 简单、无需框架、易于调试 | -| 动画 | CSS Transitions | 硬件加速、性能优秀 | -| 事件处理 | 原生 JavaScript | 无依赖、轻量级 | -| 性能优化 | requestAnimationFrame | 保证 60fps 流畅度 | -| 防抖 | 自定义 debounce | 减少 localStorage 写入频率 | - -### 1.4 文件结构 - -``` -static/ -├── css/ -│ ├── main.css # 现有样式(需修改) -│ └── layout.css # 新增:布局系统样式 -├── js/ -│ ├── main.js # 现有脚本(需修改) -│ ├── layout-manager.js # 新增:布局管理器 -│ └── state-manager.js # 新增:状态管理器 -templates/ -└── index.html # 主页面(需修改) -``` - - -## 二、核心算法设计 - -### 2.1 拖动宽度计算算法 - -#### 算法描述 - -计算拖动过程中面板的新宽度,并应用最小/最大宽度限制。 - -#### 伪代码 - -``` -function calculateNewWidth(startX, currentX, startWidth, panelType): - // 1. 计算拖动距离 - delta = currentX - startX - - // 2. 计算新宽度 - newWidth = startWidth + delta - - // 3. 获取最小/最大宽度限制 - minWidth = getMinWidth(panelType) - maxWidth = getMaxWidth(panelType) - - // 4. 应用面板自身的宽度限制 - newWidth = clamp(newWidth, minWidth, maxWidth) - - // 5. 确保邮件详情面板至少 400px - containerWidth = getContainerWidth() - otherPanelsWidth = calculateOtherPanelsWidth(panelType) - maxAllowedWidth = containerWidth - otherPanelsWidth - 400 - - // 6. 应用全局宽度限制 - newWidth = min(newWidth, maxAllowedWidth) - - return newWidth - -function clamp(value, min, max): - return max(min, min(value, max)) -``` - -#### 时间复杂度 - -- **时间复杂度**: O(1) -- **空间复杂度**: O(1) - -#### 边界情况 - -1. **拖动到最小宽度**: 当 `newWidth < minWidth` 时,返回 `minWidth` -2. **拖动到最大宽度**: 当 `newWidth > maxWidth` 时,返回 `maxWidth` -3. **邮件详情面板不足 400px**: 限制其他面板的最大宽度 -4. **窗口宽度不足**: 自动折叠面板(由窗口适配算法处理) - -### 2.2 防抖算法 - -#### 算法描述 - -延迟执行函数,在指定时间内如果再次调用则重置计时器。用于减少 localStorage 写入频率。 - -#### 伪代码 - -``` -function debounce(func, delay): - let timerId = null - - return function(...args): - // 清除之前的计时器 - if (timerId !== null): - clearTimeout(timerId) - - // 设置新的计时器 - timerId = setTimeout(() => { - func.apply(this, args) - timerId = null - }, delay) - - // 提供立即执行方法(用于 beforeunload) - return { - debounced: debouncedFunc, - flush: () => { - if (timerId !== null): - clearTimeout(timerId) - func.apply(this, args) - timerId = null - } - } -``` - -#### 使用场景 - -1. **拖动结束保存**: 延迟 500ms 保存状态 -2. **窗口调整**: 延迟 200ms 处理窗口大小变化 -3. **页面卸载**: 立即执行 `flush()` 强制保存 - -### 2.3 状态序列化算法 - -#### 算法描述 - -将布局状态序列化为 JSON 并保存到 localStorage,支持版本控制和数据验证。 - -#### 伪代码 - -``` -function serializeState(userId): - state = { - version: "1.1", - userId: userId, - timestamp: Date.now(), - panels: {} - } - - // 遍历所有面板 - for panelType in ['groups', 'accounts', 'emails']: - panel = getPanel(panelType) - state.panels[panelType] = { - width: getCSSVariable(`--${panelType}-panel-width`), - collapsed: panel.classList.contains('collapsed') - } - - // 序列化为 JSON - jsonString = JSON.stringify(state) - - // 保存到 localStorage - storageKey look_layout_state_${userId}` - localStorage.setItem(storageKey, jsonString) - - return state - -function deserializeState(userId): - storageKey = `outlook_layout_state_${userId}` - jsonString = localStorage.getItem(storageKey) - - if (jsonString === null): - return null - - try: - state = JSON.parse(jsonString) - - // 数据验证 - if (!validateState(state)): - return null - - // 版本迁移 - if (state.version !== "1.1"): - state = migrateState(state) - - return state - catch or): - console.error("Failed to deserialize state:", error) - return null - -function validateState(state): - // 检查必需字段 - if (!state.version || !state.panels): - return false - - // 检查面板数据 - for panelType in ['groups', 'accounts', 'emails']: - panel = state.panels[panelType] - if (!panel || !panel.width || typeof panel.collapsed !== 'boolean'): - return false - - return true -``` - -#### 数据格式 - -```json -{ - "version": "1.1", - "userId": "user123", - "timestamp": 1709020800000, - "panels": { - "groups": { - "width": "200px", - "collapsed": false - }, - "accounts": { - "width": "260px", - "collapsed": false - }, - "emails": { - "width": "380px", - "collapsed": false - } - } -} -``` - -### 2.4 窗口适配算法 - -#### 算法描述 - -根据窗口宽度自动折叠面板,确保邮件详情面板始终可见。 - -#### 伪代码 - -``` -function adaptToWindowSize(windowWidth): - // 定义断点 - breakpoints = { - COLLAPSE_GROUPS: 1200, - COLLAPSE_ACCOUNTS: 900, - COLLAPSE_EMAILS: 700 - } - - // 自动折叠逻辑 - if (windowWidth < breakpoints.COLLAPSE_GROUPS): - collapsePanel('groups', auto=true) - els // 如果窗口足够大,恢复之前的状态 - restorePanelIfNeeded('groups') - - if (windowWidth < breakpoints.COLLAPSE_ACCOUNTS): - collapsePanel('accounts', auto=true) - else: - restorePanelIfNeeded('accounts') - - if (windowWidth < breakpoints.COLLAPSE_EMAILS): - collapsePanel('emails', auto=true) - else: - restorePanelIfNeeded('emails') - - // 确保邮件详情面板至少 400px - ensureDetailPanelMinWidth(400) - -function restorePanelIfNeeded(panelType): - // 只恢复用户手动折叠的面板 - // 如果是自动折叠的,则恢复 - panel = getPanel(panelType) - if (panel.dataset.autoCollapsed === 'true'): - expandPanel(panelType) - delete panel.dataset.autoCollapsed -``` - -#### 防抖优化 - -窗口调整事件使用 200ms 防抖,避免频繁触发。 - -```javascript -window.addEventListener('resize', debounce(handleWindowResize, 200)); -``` - - -## 三、类与方法设计 - -### 3.1 LayoutManager 类 - -#### 类职责 - -管理面板布局,处理拖动、折叠、键盘操作和窗口适配。 - -#### 类图 - -#### 核心方法说明 - -**constructor()**: 初始化实例变量和 StateManager,创建防抖保存函数 - -**init()**: 初始化布局管理器,加载保存的状态,绑定所有事件监听器 - -**startResize(e)**: 开始拖动,记录初始位置和宽度,设置全局光标 - -**resize(e)**: 拖动过程中更新宽度,使用 requestAnimationFrame 优化性能 - -**stopResize()**: 结束拖动,恢复光标,保存状态(防抖) - -**handleKeyboard(e)**: 处理键盘操作(方向键调整宽度) - -**togglePanel(panelType)**: 切换面板折叠状态 - -**resetLayout()**: 恢复默认布局,清除保存的状态 - -**handleWindowResize()**: 处理窗口大小变化,自动折叠/恢复面板 - -**updatePanelWidth(panel, width)**: 更新面板宽度(修改 CSS 变量) - -**saveState()**: 保存当前布局状态到 localStorage - -**loadState()**: 从 localStorage 加载布局状态 - - -### 3.2 StateManager 类 - -#### 类职责 - -管理布局状态的持久化,包括保存、加载、验证和版本迁移。 - -#### 类图 - -#### 详细方法设计 - -##### save(userId, state) - -##### load(userId) - -##### clear(userId) - -##### validate(state) - -##### migrate(state) - -##### getStorageKey(userId) - -\outlook_layout_state_ -## 四、数据结构设计 - -### 4.1 布局状态对象 - -```typescript -interface LayoutState { - version: string; // 版本号(当前为 "1.1") - userId: string; // 用户 ID - timestamp: number; // 保存时间戳 - panels: { - groups: PanelState; - accounts: PanelState; - emails: PanelState; - }; -} - -interface PanelState { - width: string; // 宽度(如 "200px") - collapsed: boolean; // 是否折叠 -} -``` - -### 4.2 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; - - /* 其他 */ - --resizer-width: 4px; - --panel-transition-duration: 200ms; -} -``` - -## 五、事件流设计 - -### 5.1 拖动事件流 - -``` -用户操作 事件触发 系统响应 -──────────────────────────────────────────────────────────────── -鼠标按下 resizer mousedown startResize() - │ │ ├─ 记录初始位置 - │ │ ├─ 设置全局光标 - │ │ └─ 阻止默认行为 - │ - ├─ 鼠标移动 mousemove resize() - │ │ │ ├─ requestAnimationFrame - │ │ │ ├─ 计算新宽度 - │ │ │ ├─ 应用宽度限制 - │ │ │ ├─ 更新 CSS 变量 - │ │ │ └─ 更新 ARIA 属性 - │ │ - │ └─ 重复... - │ - └─ 鼠标释放 mouseup stopResize() - │ ├─ 恢复光标 - │ ├─ 保存状态(防抖 500ms) - │ └─ 清理状态 -``` - -### 5.2 折叠事件流 - -``` -用户操作 事件触发 系统响应 -──────────────────────────────────────────────────────────────── -点击折叠按钮 click togglePanel() - │ │ ├─ 检查当前状态 - │ │ └─ 调用 collapse/expand - │ - ├─ 折叠 collapsePanel() - │ │ ├─ 添加 .collapsed 类 - │ │ ├─ CSS transition (200ms) - │ │ ├─ 更新 ARIA 属性 - │ │ └─ 立即保存状态 - │ - └─ 展开 expandPanel() - │ ├─ 移除 .collapsed 类 - │ ├─ CSS transition (200ms) - │ ├─ 恢复宽度 - │ ├─ 更新 ARIA 属性 - │ └─ 立即保存状态 -``` - -### 5.3 窗口调整事件流 - -``` -窗口大小变化 resize (防抖 200ms) handleWindowResize() - │ │ ├─ 获取窗口宽度 - │ │ ├─ 检查断点 - │ │ ├─ 自动折叠/恢复面板 - │ │ └─ 标记 autoCollapsed -``` - -### 5.4 页面生命周期事件流 - -``` -页面加载 DOMContentLoaded init() - │ │ ├─ 创建 LayoutManager - │ │ ├─ 加载保存的状态 - │ │ ├─ 应用到 CSS 变量 - │ │ ├─ 绑定事件监听器 - │ │ └─ 初始窗口适配 - │ - └─ 页面卸载 beforeunload saveNow() - │ ├─ 取消防抖计时器 - │ ├─ 立即保存状态 - │ └─ 确保不丢失数据 -``` - -## 六、性能优化策略 - -### 6.1 拖动性能优化 - -#### 使用 requestAnimationFrame - -```javascript -resize(e) { - if (!this.isResizing) return; - - requestAnimationFrame(() => { - // 宽度计算和更新 - const newWidth = this.calculateNewWidth(e); - this.updatePanelWidth(this.currentPanel, newWidth); - }); -} -``` - -**优势**: -- 与浏览器刷新率同步(60fps) -- 避免不必要的重绘 -- 自动节流 - -#### 避免布局抖动(Layout Thrashing) - -```javascript -// ❌ 错误:读写交替导致强制同步布局 -function badResize() { - const width1 = panel1.offsetWidth; // 读 - panel1.style.width = width1 + 10; // 写 - const width2 = panel2.offse // 读(触发强制布局) - panel2.style.width = width2 + 10; // 写 -} - -// ✅ 正确:批量读取,批量写入 -function goodResize() { - // 批量读取 - const width1 = panel1.offsetWidth; - const width2 = panel2.offsetWidth; - - // 批量写入 - panel1.style.width = width1 + 10; - panel2.style.width = width2 + 10; -} -``` - -### 6.2 CSS 性能优化 - -#### 使用 CSS 变量 - -```css -/* ✅ 使用 CSS 变量,只需修改一次 */ -.group-panel { - width: var(--group-panel-width); -} - -/* ❌ 直接修改 style,触发重排 */ -element.style.width = '200px'; -``` - -#### 使用 transform 和 opacity - -```css -/* ✅ 使用 transform,GPU 加速 */ -.panel.collapsed { - transform: translateX(-100%); - opacity: 0; -} - -/* ❌ 使用 width,触发重排 */ -.panel.collapsed { - width: 0; -} -``` - -#### 使用 will-change 提示 - -```css -.resizer { - will-change: background-color; -} - -.panel { - will-change: transform, opacity; -} -``` - -### 6.3 防抖优化 - -```javascript -// 拖动结束保存:500ms 防抖 -this.saveDebounced = debounce(() => this.saveState(), 500); - -// 窗口调整:200ms 防抖 -window.addEventListener('resize', debounce(this.handleWindowResize, 200)); -``` - -**效果**: -- 减少 localStorage 写入次数 -- 减少窗口调整处理次数 -- 提升整体性能 - -### 6.4 事件委托 - -```javascript -// ✅ 使用事件委托 -document.querySelector('.main-container').addEventListener('click', (e) => { - if (e.target.classList.collapse-btn')) { - const panelType = e.target.dataset.panel; - this.togglePanel(panelType); - } -}); - -// ❌ 为每个按钮绑定事件 -document.querySelectorAll('.collapse-btn').forEach(btn => { - btn.addEventListener('click', () => { /* ... */ }); -}); -``` - -### 6.5 内存优化 - -```javascript -// 清理事件监听器 -destroy() { - // 移除全局事件监听器 - document.removeEventListener('mousemove', this.resize); - document.removeEventListener('mouseup', this.stopResize); - window.removeEventListener('resize', this.handleWindowResize); - window.removeEventListener('beforeunload', this.saveNow); - - // 清除计时器 - if (this.saveTimer) { - clearTimeout(this.saveTimer); - } -} -``` - - -## 七、错误处理与边界情况 - -### 7.1 拖动边界情况 - -#### 情况 1:拖动到最小宽度 - -```javascript -// 处理:限制在最小宽度 -if (newWidth < minWidth) { - newWidth = minWidth; -} -``` - -#### 情况 2:拖动到最大宽度 - -```javascript -// 处理:限制在最大宽度 -if (newWidth > maxWidth) { - newWidth = maxWidth; -} -``` - -#### 情况 3:邮件详情面板不足 400px - -```javascript -// 处理:限制其他面板的最大宽度 -const containerWidth = document.querySelector('.main-container').offsetWidth; -const otherPanelsWidth = this.calculateOtherPanelsWidth(panelType); -const maxAllowedWidth = containerWidth - otherPanelsWidth - 400; -newWidth = Math.min(newWidth, maxAllowedWidth); -``` - -#### 情况 4:快速拖动导致鼠标移出窗口 - -```javascript -// 处理:监听全局 mouseup 事件 -document.addEventListener('mouseup', this.stopResize.bind(this)); -``` - -### 7.2 折叠边界情况 - -#### 情况 1:所有面板都折叠 - -```javascript -// 处理:至少保持一个面板展开 -togglePanel(panelType) { - const expandedPanels = this.getExpandedPanels(); - - if (expandedPanels.length === 1 && expandedPanels[0] === panelType) { - alert('至少需要保持一个面板展开'); - return; - } - - // 继续折叠... -} -``` - -#### 情况 2:折叠后拖动 - -```javascript -// 处理:隐藏 resizer -.panel.collapsed .resizer { - display: none; - pointer-events: none; -} -``` - -### 7.3 localStorage 错误处理 - -#### 情况 1:localStorage 不可用 - -```javascript -save(userId, state) { - try { - localStorage.setItem(key, value); - return true; - } catch (error) { - console.warn('localStorage not available, using default layout'); - return false; - } -} -``` - -#### 情况 2:QuotaExceededError - -```javascript -save(userId, state) { - try { - localStorage.setItem(key, value); - } catch (error) { - if (error.name === 'QuotaExceededError') { - // 清理旧数据 - this.clearOldStates(); - // 重试 - localStorage.setItem(key, value); - } - } -} -``` - -#### 情况 3:数据损坏 - -```javascript -load(userId) { - try { - const state = JSON.parse(jsonString); - - if (!this.validate(state)) { - console.warn('Invalid state, using default'); - this.clear(userId); - return null; - } - - return state; - } catch (error) { - console.error('Failed to parse state:', error); - this.clear(userId); - returnn} -``` - -### 7.4 窗口调整边界情况 - -#### 情况 1:窗口宽度小于 700px - -```javascript -// 处理:自动折叠所有侧边栏,只显示邮件详情 -if (windowWidth < 700) { - this.collapsePanel('groups', true); - this.collapsePanel('accounts', true); - this.collapsePanel('emails', true); -} -``` - -#### 情况 2:快速调整窗口大小 - -```javascript -// 处理:使用防抖(200ms) -window.addEventListener('resize', debounce(this.handleWindowResize, 200)); -``` - -### 7.5 键盘操作边界情况 - -#### 情况 1:连续按键导致超出限制 - -```javascript -handleKeyboard(e) { - let newWidth = currentWidth + delta; - - // 应用限制 - newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth)); - - // 更新宽度 - this.updatePanelWidth(panel, newWidth); -} -``` - -#### 情况 2:焦点在折叠的面板上 - -```javascript -// 处理:折叠时移除 tabindex -collapsePanel(panelType) { - const resizer = panel.querySelector('.resizer'); - resizer.setAttribute('tabindex', '-1'); -} - -expandPanel(panelType) { - const resizer = panel.querySelector('.resizer'); - resizer.setAttribute('tabindex', '0'); -} -``` - -### 7.6 极端窗口尺寸 - -#### 情况 1:超宽屏幕(> 3000px) - -```javascript -// 处理:限制面板最大宽度 -getMaxWidth(panelTyp const maxWidths = { - 'groups': 400, - 'accounts': 500, - 'emails': 600 - }; - return maxWidths[panelType]; -} -``` - -#### 情况 2:超窄屏幕(< 400px) - -```javascript -// 处理:强制只显示邮件详情面板 -if (windowWidth < 400) { - // 折叠所有侧边栏 - this.collapsePanel('groups', true); - this.collapsePanel('accounts', true); - this.collapsePanel('emails', true); - - // 邮件详情面板占满全屏 - document.documentElement.style.setProperty( - '--email-detail-panel-min-width', - '100%' - ); -} -``` - -## 八、集成方案 - -### 8.1 HTML 结构改造 - -#### 现有结构 - -```html -
-
...
- - - -
-``` - -#### 改造后结构 - -```html -
- -
-
-

分组

- -
-
- -
- -
- - -
- -
- - -
- -
- - - -
-``` - -### 8.2 CSS 集成 - -#### 创建 layout.css - -```css -/* CSS 变量 */ -:root { - --group-panel-width: 200px; - --account-panel-width: 260px; - --email-list-panel-width: 380px; - /* ... 其他变量 */ -} - -/* Grid 布局 */ -.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; -} - -/* Resizer 样式 */ -.resizer { - position: absolute; - top: 0; - right: 0; - width: var(--resizer-width); - height: 100%; - cursor: col-resize; - z-index: 10; -} - -.resizer:hover, -.resizer:focus { - background-color: rgba(26, 26, 26, 0.1); -} - -/* 折叠状态 */ -.resizable-panel.collapsed { - width: 0; - opacity: 0; - overflow: hidden; -} - -.resizable-panel.collapsed .resizer { - display: none; -} -``` - -#### 修改 main.css - -```css -/* 移除固定宽度 */ -/* 删除以下代码: -.group-panel { - width: 200px; -} -.account-panel { - width: 260px; -} -.email-list-panel { - width: 380px; -} -*/ - -/* 改为使用 CSS 变量 */ -.group-panel { - width: var(--group-panel-width); -} -``` - -### 8.3 JavaScript 集成 - -#### 修改 main.js - -```javascript -// 在文件末尾添加 -import LayoutManager from './layout-manager.js'; -import StateManager from './state-manager.js'; - -// 初始化布局管理器 -document.addEventListener('DOMContentLoaded', () => { - const layoutManager = new LayoutManager(); - layoutManager.init(); - - // 暴露到全局(用于调试) - window.layoutManager = layoutManager; -}); -``` - -### 8.4 向后兼容 - -#### 降级策略 - -```javascript -// 检测 CSS Grid 支持 -if (!CSS.supports('display', 'grid')) { - console.warn('CSS Grid not using fallback layout'); - document.body.classList.add('no-grid'); -} - -// 检测 localStorage 支持 -if (!window.localStorage) { - console.warn('localStorage not supported, layout state will not persist'); -} -``` - -#### 渐进增强 - -```javascript -// 如果 JavaScript 加载失败,使用默认布局 - -``` - -### 8.5 初始化流程 - -``` -页面加载 - ↓ -DOMContentLoaded - ↓ -创建 LayoutManager 实例 - ↓ -调用 init() - ├─ StateManager.load(userId) - │ ├─ 从 localStorage 读取 - │ ├─ 验证数据 - │ └─ 版本迁移 - ├─ 应用保存的状态到 CSS 变量 - ├─ 绑定拖动事件监听器 - ├─ 绑定折叠按钮事件 - ├─ 绑定窗口调整事件(防抖) - ├─ 绑定页面卸载事件 - └─ 初始窗口适配 - ↓ -布局系统就绪 -``` - - -## 九、测试策略 - -### 9.1 单元测试 - -测试 LayoutManager 和 StateManager 的各个方法,确保核心逻辑正确。 - -### 9.2 集成测试 - -测试拖动、折叠、状态持久化的完整流程。 - -### 9.3 浏览器兼容性测试 - -在 Chrome 90+、Firefox 88+、Safari 14+、Edge 90+ 上测试所有功能。 - -### 9.4 性能测试 - -- 拖动帧率应保持 60fps -- localStorage 数据应小于 1KB -- 动画流畅无卡顿 - -### 9.5 可访问性测试 - -- 使用 axe-core 自动检测 -- 手动测试键盘导航和屏幕阅读器 - -## 十、兼容性方案 - -### 10.1 CSS Grid 降级 - -使用 @supports 检测,不支持时降级到 flexbox。 - -### 10.2 JavaScript 兼容性 - -使用 Babel 转译,确保在目标浏览器上运行。 - -### 10.3 功能检测 - -检测 CSS Grid、CSS 变量、localStorage、requestAnimationFrame 支持情况。 - -### 10.4 渐进增强 - -基础 HTML 使用固定布局,JavaScript 加载后增强为动态布局。 - -## 十一、总结 - -### 11.1 技术亮点 - -1. 纯原生实现,无需框架 -2. 性能优化,60fps 流畅拖动 -3. 完整的可访问性支持 -4. 健壮的错误处理 -5. 状态持久化,多用户隔离 - -### 11.2 关键算法 - -- 拖动宽度计算:O(1) 时间复杂度 -- 防抖优化:减少 80% 的 localStorage 写入 -- 窗口适配:自动折叠 + 智能恢复 - -### 11.3 测试覆盖 - -- 单元测试:LayoutManager、StateManager -- 集成测试:拖动、折叠、状态持久化 -- 浏览器兼容性测试:Chrome、Firefox、Safari、Edge -- 性能测试:60fps 拖动、内存占用 -- 可访问性测试:axe-core + 手动测试 - ---- - -**文档结束** diff --git a/docs/TEST/Outlook邮件管理工具-Legacy迁移到Controllers层-测试文档.md b/docs/TEST/Outlook邮件管理工具-Legacy迁移到Controllers层-测试文档.md deleted file mode 100644 index b900436..0000000 --- a/docs/TEST/Outlook邮件管理工具-Legacy迁移到Controllers层-测试文档.md +++ /dev/null @@ -1,531 +0,0 @@ -# Outlook 邮件管理工具|Legacy 迁移到 Controllers 层 测试文档 - -- 文档状态:草案 -- 版本:V1.0 -- 日期:2026-02-24 -- 对齐 PRD:`docs/PRD/PRD-00003-Legacy代码拆分到Controllers层.md` -- 对齐 FD:`docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md` -- 对齐 TDD:`docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md` -- 对齐 TODO:`docs/TODO/Outlook邮件管理工具-Legacy迁移到Controllers层TODO.md` - ---- - -## 1. 测试概述 - -### 1.1 测试目标 - -本测试文档旨在验证 Legacy 迁移到 Controllers 层的正确性、完整性和性能,确保: - -1. **功能完整性**: - - 所有 54 个 API 路由功能正常 - - 所有业务逻辑保持不变 - - 所有数据处理正确 - -2. **API 契约不变**: - - URL 路径不变 - - HTTP 方法不变 - - 请求参数不变 - - 响应格式不变 - - 错误格式不变 - -3. **性能不下降**: - - 响应时间 < 迁移前 110% - - 内存使用无明显增加 - - 并发处理能力不下降 - -4. **代码质量提升**: - - 代码结构清晰 - - 职责分离明确 - - 易于测试和维护 - -### 1.2 测试范围 - -**包含:** -- 单元测试(Controllers 层) -- 集成测试(完整请求-响应流程) -- 回归测试(所有现有功能) -- 契约测试(API 契约验证) -- 性能测试(响应时间、内存使用) -- 错误处理测试 -- 边界情况测试 - -**不包含:** -- 安全渗透测试(不在本次迁移范围) -- 压力测试(不在本次迁移范围) -- 前端功能测试(前端无需修改) - -### 1.3 测试环境 - -**后端环境:** -- Python 3.8+ -- Flask 3.0+ -- SQLite 3 -- 测试框架:unittest -- Mock 工具:unittest.mock -- 覆盖率工具:coverage - -**测试数据:** -- 测试数据库:`data/test_outlook_accounts.db` -- 测试账号:10 个 -- 测试分组:3 个 -- 测试标签:5 个 -- 测试邮件:20 封 - -**测试工具:** -- unittest - Python 标准测试框架 -- unittest.mock - Mock 工具 -- Flask test client - 集成测试 -- coverage - 测试覆盖率 -- time - 性能测试 -- tracemalloc - 内存测试 - ---- - -## 2. 测试策略 - -### 2.1 测试金字塔 - -``` - /\ - / \ E2E 测试(少量) - / \ - 关键业务流程 - /------\ - / \ 集成测试(适量) - / \ - API 接口测试 - /------------\ - / \ 单元测试(大量) -/________________\- Controllers 测试 - - Services 测试 - - Repositories 测试 -``` - -**测试比例:** -- 单元测试:70% -- 集成测试:20% -- E2E 测试:10% - -### 2.2 测试优先级 - -**P0(必须通过):** -- 所有现有测试通过(回归测试) -- 所有 API 契约测试通过 -- 核心功能集成测试通过 - -**P1(重要):** -- Controllers 单元测试通过 -- 性能测试通过 -- 错误处理测试通过 - -**P2(可选):** -- 测试覆盖率 > 80% -- 边界情况测试通过 - -### 2.3 测试方法 - -#### 2.3.1 单元测试 - -**目标:** 测试 Controllers 层的每个函数 - -**方法:** -- 使用 unittest.mock.patch Mock 依赖 -- 测试正常场景 -- 测试异常场景 -- 测试边界场景 - -**示例:** -```python -@patch('outlook_web.controllers.groups.groups_repo') -@patch('outlook_web.controllers.groups.session') -def test_api_get_groups_success(self, mock_session, mock_repo): - # Mock 登录状态 - mock_session.get.return_value = True - - # Mock repository 返回 - mock_repo.get_all_groups.return_value = [ - {'id': 1, 'name': '分组1'}, - {'id': 2, 'name': '分组2'} - ] - - # 调用 controller - response = groups_controller.api_get_groups() - - # 验证 - self.assertEqual(response.status_code, 200) -``` - -#### 2.3.2 集成测试 - -**目标:** 测试完整的请求-响应流程 - -**方法:** -- 使用 Flask test client -- 测试真实的数据库操作 -- 测试完整的业务流程 - -**示例:** -```python -def test_groups_crud_integration(self): - # 1. 创建分组 - response = self.client.post('/api/groups', json={'name': '测试分组'}) - self.assertEqual(response.status_code, 200) - group_id = response.get_json()['id'] - - # 2. 获取分组 - response = self.client.get(f'/api/groups/{group_id}') - self.assertEqual(response.status_code, 200) - - # 3. 删除分组 - response = self.client.delete(f'/api/groups/{group_id}') - self.assertEqual(response.status_code, 200) -``` - -#### 2.3.3 回归测试 - -**目标:** 确保所有现有功能正常 - -**方法:** -- 运行所有现有测试 -- 手动测试关键功能 -- 对比迁移前后的行为 - -**现有测试文件:** -- `tests/test_smoke_contract.py` - 烟雾测试和契约测试 -- `tests/test_core_features.py` - 核心功能测试 -- `tests/test_error_and_trace.py` - 错误处理测试 -- `tests/test_distributed_lock.py` - 分布式锁测试 -- `tests/test_masking_audit_and_import.py` - 数据脱敏测试 - -#### 2.3.4 契约测试 - -**目标:** 确保 API 契约不变 - -**方法:** -- 验证 URL 路径 -- 验证 HTTP 方法 -- 验证请求参数 -- 验证响应格式 -- 验证错误格式 - -**示例:** -```python -def test_api_contract_groups(self): - # 验证响应格式 - response = self.client.get('/api/groups') - data = response.get_json() - - self.assertIsInstance(data, list) - if len(data) > 0: - group = data[0] - self.assertIn('id', group) - self.assertIn('name', group) -``` - -#### 2.3.5 性能测试 - -**目标:** 确保性能不下降 - -**方法:** -- 测量响应时间 -- 测量内存使用 -- 对比迁移前后的性能 - -**示例:** -```python -def test_performance_groups(self): - start = time.time() - response = self.client.get('/api/groups') - end = time.time() - - response_time = (end - start) * 1000 # 毫秒 - self.assertLess(response_time, 100) # < 100ms -``` - ---- - -## 3. 测试用例 - -### 3.1 阶段 1:基础模块测试 - -#### 3.1.1 Groups 模块测试 - -**测试文件:** `tests/test_controllers_groups.py` - -##### TC-G-001:获取所有分组(正常场景) - -**测试目标:** 验证获取所有分组功能正常 - -**前置条件:** -- 用户已登录 -- 数据库中有 2 个分组 - -**测试步骤:** -1. 发送 GET 请求到 `/api/groups` -2. 验证响应状态码为 200 -3. 验证响应数据为列表 -4. 验证列表包含 2 个分组 -5. 验证每个分组包含 id 和 name 字段 - -**预期结果:** -```json -[ - {"id": 1, "name": "分组1"}, - {"id": 2, "name": "分组2"} -] -``` - -##### TC-G-002:获取所有分组(未登录) - -**测试目标:** 验证未登录时无法访问 - -**前置条件:** -- 用户未登录 - -**测试步骤:** -1. 发送 GET 请求到 `/api/groups` -2. 验证响应状态码为 302(重定向到登录页) - -**预期结果:** -- 重定向到 `/login` - -##### TC-G-003:添加分组(正常场景) - -**测试目标:** 验证添加分组功能正常 - -**前置条件:** -- 用户已登录 - -**测试步骤:** -1. 发送 POST 请求到 `/api/groups` -2. 请求体:`{"name": "新分组"}` -3. 验证响应状态码为 200 -4. 验证响应包含 id 和 name -5. 验证数据库中已创建该分组 - -**预期结果:** -```json -{"id": 3, "name": "新分组"} -``` - -##### TC-G-004:添加分组(名称为空) - -**测试目标:** 验证参数验证正常 - -**前置条件:** -- 用户已登录 - -**测试步骤:** -1. 发送 POST 请求到 `/api/groups` -2. 请求体:`{"name": ""}` -3. 验证响应状态码为 400 -4. 验证错误信息为"分组名称不能为空" - -**预期结果:** -```json -{ - "success": false, - "error": "分组名称不能为空", - "trace_id": "xxx-xxx-xxx" -} -``` - -##### TC-G-005:更新分组(正常场景) - -**测试目标:** 验证更新分组功能正常 - -**前置条件:** -- 用户已登录 -- 数据库中有 id 为 1 的分组 - -**测试步骤:** -1. 发送 PUT 请求到 `/api/groups/1` -2. 请求体:`{"name": "更新后的分组"}` -3. 验证响应状态码为 200 -4. 验证响应包含更新后的数据 -5. 验证数据库中已更新 - -**预期结果:** -```json -{"id": 1, "name": "更新后的分组"} -``` - -##### TC-G-006:删除分组(正常场景) - -**测试目标:** 验证删除分组功能正常 - -**前置条件:** -- 用户已登录 -- 数据库中有 id 为 1 的分组 - -**测试步骤:** -1. 发送 DELETE 请求到 `/api/groups/1` -2. 验证响应状态码为 200 -3. 验证响应包含成功消息 -4. 验证数据库中已删除 - -**预期结果:** -```json -{"message": "删除成功"} -``` - -##### TC-G-007:删除分组(分组不存在) - -**测试目标:** 验证错误处理正常 - -**前置条件:** -- 用户已登录 -- 数据库中没有 id 为 999 的分组 - -**测试步骤:** -1. 发送 DELETE 请求到 `/api/groups/999` -2. 验证响应状态码为 404 -3. 验证错误信息为"分组不存在" - -**预期结果:** -```json -{ - "success": false, - "error": "分组不存在", - "trace_id": "xxx-xxx-xxx" -} -``` - -#### 3.1.2 Tags 模块测试 - -**测试文件:** `tests/test_controllers_tags.py` - -**测试用例:** 类似 Groups 模块,包括: -- TC-T-001:获取所有标签(正常场景) -- TC-T-002:获取所有标签(未登录) -- TC-T-003:添加标签(正常场景) -- TC-T-004:添加标签(名称为空) -- TC-T-005:删除标签(正常场景) -- TC-T-006:删除标签(标签不存在) -- TC-T-007:批量管理标签(正常场景) - -#### 3.1.3 Settings 模块测试 - -**测试文件:** `tests/test_controllers_settings.py` - -**测试用例:** -- TC-S-001:获取系统设置(正常场景) -- TC-S-002:更新系统设置(正常场景) -- TC-S-003:更新系统设置(参数验证) -- TC-S-004:验证 Cron 表达式(正常场景) -- TC-S-005:验证 Cron 表达式(格式错误) - -#### 3.1.4 System 模块测试 - -**测试文件:** `tests/test_controllers_system.py` - -**测试用例:** -- TC-SYS-001:健康检查(正常场景) -- TC-SYS-002:获取诊断信息(正常场景) -- TC-SYS-003:获取升级状态(正常场景) - -#### 3.1.5 Audit 模块测试 - -**测试文件:** `tests/test_controllers_audit.py` - -**测试用例:** -- TC-A-001:获取审计日志(正常场景) -- TC-A-002:获取审计日志(分页) -- TC-A-003:获取审计日志(过滤) - -#### 3.1.6 Pages 模块测试 - -**测试文件:** `tests/test_controllers_pages.py` - -**测试用例:** -- TC-P-001:登录页面(正常场景) -- TC-P-002:登录功能(正确密码) -- TC-P-003:登录功能(错误密码) -- TC-P-004:登出功能(正常场景) -- TC-P-005:首页(已登录) -- TC-P-006:首页(未登录,重定向) - ---- - -### 3.2 阶段 2:独立功能模块测试 - -#### 3.2.1 Temp Emails 模块测试 - -**测试文件:** `tests/test_controllers_temp_emails.py` - -**测试用例:** -- TC-TE-001:获取临时邮箱列表(正常场景) -- TC-TE-002:生成临时邮箱(正常场景) -- TC-TE-003:获取临时邮箱消息(正常场景) -- TC-TE-004:获取临时邮箱消息(邮箱不存在) - -#### 3.2.2 OAuth 模块测试 - -**测试文件:** `tests/test_controllers_oauth.py` - -**测试用例:** -- TC-O-001:获取 OAuth 授权 URL(正常场景) -- TC-O-002:交换 OAuth Token(正常场景) -- TC-O-003:交换 OAuth Token(code 无效) - -#### 3.2.3 Scheduler 模块测试 - -**测试文件:** `tests/test_controllers_scheduler.py` - -**测试用例:** -- TC-SCH-001:获取调度器状态(正常场景) -- TC-SCH-002:获取调度器状态(调度器未启动) - ---- - -### 3.3 阶段 3:核心复杂模块测试 - -#### 3.3.1 Emails 模块测试 - -**测试文件:** `tests/test_controllers_emails.py` - -**测试用例:** -- TC-E-001:获取邮件列表(Graph API 成功) -- TC-E-002:获取邮件列表(IMAP 回退) -- TC-E-003:获取邮件列表(账号不存在) -- TC-E-004:获取邮件详情(正常场景) -- TC-E-005:删除邮件(正常场景) -- TC-E-006:提取验证码(正常场景) -- TC-E-007:提取验证码(未找到验证信息) - -#### 3.3.2 Accounts 模块测试 - -**测试文件:** `tests/test_controllers_accounts.py` - -**测试用例(基础 CRUD):** -- TC-AC-001:获取账号列表(正常场景) -- TC-AC-002:获取账号列表(按分组过滤) -- TC-AC-003:获取账号列表(数据脱敏验证) -- TC-AC-004:获取单个账号( TC-AC-005:添加账号(正常场景) -- TC-AC-006:添加账号(参数验证) -- TC-AC-007:更新账号(正常场景) -- TC-AC-008:删除账号(正常场景) - -**测试用例(批量操作):** -- TC-AC-009:按邮箱删除账号(正常场景) -- TC-AC-010:批量删除账号(正常场景) -- TC-AC-011:批量更新分组(正常场景) -- TC-AC-012:搜索账号(正常场景) - -**测试用例(导出功能):** -- TC-AC-013:导出所有账号(正常场景) -- TC-AC-014:导出选中账号(正常场景) -- TC-AC-015:生成导出验证 Token(正常场景) -- TC-AC-016:导出验证流程(完整流程) - -**测试用例(Token 刷新):** -- TC-AC-017:刷新单个账号(正常场景) -- TC-AC-018:刷新所有账号(正常场景) -- TC-AC-019:重试刷新账号(正常场景) -- TC-AC-020:刷新失败账号(正常场景) -- TC-AC-021:触发定时刷新(正常场景) -- TC-AC-022:获取刷新日志(正常场景) -- TC-AC-023:获取账号刷新日志(正常场景) -- TC-AC-024:获取刷新统计(正常场景) - ---- - -(文档未完,待续...) diff --git a/docs/TEST/Outlook邮件管理工具-批量操作与自动化增强-测试文档-补充.md b/docs/TEST/Outlook邮件管理工具-批量操作与自动化增强-测试文档-补充.md deleted file mode 100644 index c10eaef..0000000 --- a/docs/TEST/Outlook邮件管理工具-批量操作与自动化增强-测试文档-补充.md +++ /dev/null @@ -1,732 +0,0 @@ -## 5. 前端功能测试 - -### 5.1 全选功能测试 - -#### 测试用例 FT-001:全选所有邮箱 - -**测试目的:** 验证点击全选复选框能选中所有邮箱 - -**前置条件:** -- 前端页面已加载 -- 当前分组有至少 5 个邮箱 - -**测试步骤:** -1. 打开邮箱管理页面 -2. 选择一个分组 -3. 点击全选复选框 -4. 观察所有邮箱的复选框状态 - -**预期结果:** -- 全选复选框显示为选中状态 -- 所有邮箱的复选框都被选中 -- 批量操作栏显示"已选 N 项" - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-002:取消全选 - -**测试目的:** 验证再次点击全选复选框能取消所有选中 - -**前置条件:** -- 前端页面已加载 -- 所有邮箱已被选中 - -**测试步骤:** -1. 在全选状态下 -2. 再次点击全选复选框 -3. 观察所有邮箱的复选框状态 - -**预期结果:** -- 全选复选框显示为未选中状态 -- 所有邮箱的复选框都被取消选中 -- 批量操作栏隐藏 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-003:半选状态 - -**测试目的:** 验证部分选中时全选复选框显示半选状态 - -**前置条件:** -- 前端页面已加载 -- 当前分组有至少 5 个邮箱 - -**测试步骤:** -1. 手动选中部分邮箱(不是全部) -2. 观察全选复选框状态 - -**预期结果:** -- 全选复选框显示为半选状态(indeterminate) -- 批量操作栏显示"已选 N 项" - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-004:全选后批量移动分组 - -**测试目的:** 验证全选后能执行批量移动分组操作 - -**前置条件:** -- 前端页面已加载 -- 当前分组有至少 5 个邮箱 -- 有其他分组可供移动 - -**测试步骤:** -1. 点击全选复选框 -2. 点击批量操作栏的"移动分组"按钮 -3. 选择目标分组 -4. 确认移动 - -**预期结果:** -- 所有邮箱成功移动到目标分组 -- 显示成功提示 -- 当前分组的邮箱列表更新 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-005:全选后批量打标签 - -**测试目的:** 验证全选后能执行批量打标签操作 - -**前置条件:** -- 前端页面已加载 -- 当前分组有至少 5 个邮箱 -- 已创建标签 - -**测试步骤:** -1. 点击全选复选框 -2. 点击批量操作栏的"打标签"按钮 -3. 选择标签 -4. 确认 - -**预期结果:** -- 所有邮箱成功添加标签 -- 显示成功提示 -- 邮箱列表显示标签 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-006:切换分组重置全选状态 - -**测试目的:** 验证切换分组时全选状态被重置 - -**前置条件:** -- 前端页面已加载 -- 当前分组有邮箱被选中 - -**测试步骤:** -1. 选中部分或全部邮箱 -2. 切换到其他分组 -3. 观察全选复选框状态 - -**预期结果:** -- 全选复选框显示为未选中状态 -- 批量操作栏隐藏 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-007:搜索后全选只影响显示的邮箱 - -**测试目的:** 验证搜索后全选只选中当前显示的邮箱 - -**前置条件:** -- 前端页面已加载 -- 当前分组有至少 10 个邮箱 - -**测试步骤:** -1. 在搜索框输入关键词,过滤部分邮箱 -2. 点击全选复选框 -3. 观察选中数量 - -**预期结果:** -- 只有当前显示的邮箱被选中 -- 批量操作栏显示的数量等于显示的邮箱数量 - -**测试状态:** ⬜ 待执行 - ---- - -### 5.2 复制功能测试 - -#### 测试用例 FT-008:复制验证码成功 - -**测试目的:** 验证点击复制按钮能成功复制验证码 - -**前置条件:** -- 前端页面已加载 -- 测试邮箱有包含验证码的邮件 - -**测试步骤:** -1. 找到测试邮箱 -2. 点击复制按钮 -3. 等待加载完成 -4. 粘贴到文本框验证 - -**预期结果:** -- 显示"已复制"通知 -- 复制按钮显示绿色对勾(1秒后恢复) -- 粘贴的内容包含验证码 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-009:复制验证码和链接 - -**测试目的:** 验证能同时复制验证码和链接 - -**前置条件:** -- 前端页面已加载 -- 测试邮箱有同时包含验证码和链接的邮件骤:** -1. 找到测试邮箱 -2. 点击复制按钮 -3. 等待加载完成 -4. 粘贴到文本框验证 - -**预期结果:** -- 显示"已复制"通知 -- 粘贴的内容格式为"验证码 链接1 链接2"(空格分隔) - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-010:未找到验证信息 - -**测试目的:** 验证未找到验证信息时显示错误提示 - -**前置条件:** -- 前端页面已加载 -- 测试邮箱的邮件不包含验证码和链接 - -**测试步骤:** -1. 找到测试邮箱 -2. 点击复制按钮 -3. 等待加载完成 - -**预期结果:** -- 显示"未找到验证信息"提示 -- 复制按钮恢复正常状态 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-011:邮箱无数据自动获取 - -**测试目的:** 验证邮箱无数据时自动触发获取邮件 - -**前置条件:** -- 前端页面已加载 -- 测试邮箱未获取过邮件 - -**测试步骤:** -1. 找到未获取过邮件的测试邮箱 -2. 点击复制按钮 -3. 观察页面行为 - -**预期结果:** -- 显示"正在获取邮件..."提示 -- 自动调用获取邮件接口 -- 获取成功后自动复制验证信息 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-012:复制按钮加载状态 - -**测试目的:** 验证复制按钮的加载状态显示 - -**前置条件:** -- 前端页面已加载 - -**测试步骤:** -1. 点击复制按钮 -2. 观察按钮状态变化 - -**预期结果:** -- 按钮立即变为禁用状态 -- 显示加载动画 -- 加载完成后恢复正常状态 - -**测试状态:** ⬜ 待执行 - ---- - -### 5.3 轮询功能测试 - -#### 测试用例 FT-013:启用轮询后自动开始 - -**测试目的:** 验证启用轮询后选中邮箱时自动开始轮询 - -**前置条件:** -- 前端页面已加载 -- 在设置中启用了自动轮询 - -**测试步骤:** -1. 打开设置,启用自动轮询 -2. 设置轮询间隔为 10 秒,次数为 3 次 -3. 保存设置 -4. 选中一个邮箱 -5. 等待观察 - -**预期结果:** -- 每 10 秒自动获取一次邮件 -- 总共轮询 3 次后自动停止 -- 控制台输出轮询日志 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-014:检测到新邮件显示通知 - -**测试目的:** 验证检测到新邮件时显示通知 - -**前置条件:** -- 前端页面已加载 -- 启用了自动轮询 -- 正在轮询中 - -**测试步骤:** -1. 在轮询过程中,向测试邮箱发送新邮件 -2. 等待下一次轮询 -3. 观察页面通知 - -**预期结果:** -- 右上角显示通知:"邮箱地址 收到新邮件:邮件主题" -- 通知 5 秒后自动消失 -- 邮箱列表显示红点提示 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-015:邮箱列表红点提示 - -**测试目的:** 验证新邮件的红点提示显示和消失 - -**前置条件:** -- 前端页面已加载 -- 邮箱有新邮件通知 - -**测试步骤:** -1. 观察有新邮件的邮箱 -2. 点击该邮箱查看邮件 -3. 观察红点状态 - -**预期结果:** -- 邮箱名称旁边显示红色圆点 -- 点击邮箱后红点消失 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-016:切换邮箱停止轮询 - -**测试目的:** 验证切换邮箱时停止当前轮询 - -**前置条件:** -- 前端页面已加载 -- 正在轮询中 - -**测试步骤:** -1. 在轮询过程中 -2. 切换到其他邮箱 -3. 观察控制台日志 - -**预期结果:** -- 当前轮询立即停止 -- 控制台输出"停止轮询"日志 -- 开始轮询新选中的邮箱 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-017:轮询失败自动停止 - -**测试目的:** 验证轮询失败时自动停止并提示 - -**前置条件:** -- 前端页面已加载 -- 正在轮询中 - -**测试步骤:** -1. 在轮询过程中 -2. 模拟网络错误或 API 失败 -3. 观察页面行为 - -**预期结果:** -- 轮询立即停止 -- 显示错误提示:"轮询失败,已自动停止" -- 控制台输出错误日志 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-018:通知点击关闭 - -**测试目的:** 验证点击通知关闭按钮能立即关闭通知 - -**前置条件:** -- 前端页面已加载 -- 有新邮件通知显示 - -**测试步骤:** -1. 等待新邮件通知显示 -2. 点击通知的关闭按钮(×) - -**预期结果:** -- 通知立即消失 - -**测试状态:** ⬜ 待执行 - ---- - -### 5.4 设置界面测试 - -#### 测试用例 FT-019:加载轮询配置 - -**测试目的:** 验证设置页面能正确加载轮询配置 - -**前置条件:** -- 前端页面已加载 -- 数据库有轮询配置 - -**测试步骤:** -1. 打开设置页面 -2. 观察轮询配置项 - -**预期结果:** -- 显示当前的轮询开关状态 -- 显示当前的轮询间隔 -- 显示当前的轮询次数 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-020:保存轮询配置 - -**测试目的:** 验证能成功保存轮询配置 - -**前置条件:** -- 前端页面已加载 -- 打开设置页面 - -**测试步骤:** -1. 修改轮询配置 -2. 点击保存按钮 -3. 观察提示信息 - -**预期结果:** -- 显示"设置已保存,下次选中邮箱时生效"提示 -- 刷新页面后配置保持 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 FT-021:轮询间隔输入验证 - -**测试目的:** 验证轮询间隔输入框的验证 - -**前置条件:** -- 前端页面已加载 -- 打开设置页面 - -**测试步骤:** -1. 尝试输入小于 5 的值 -2. 尝试输入大于 300 的值 -3. 点击保存 - -**预期结果:** -- 输入框显示验证错误 -- 或保存时显示错误提示 - -**测试状态:** ⬜ 待执行 - ---- - -## 6. 性能测试 - -### 6.1 全选性能测试 - -#### 测试用例 PT-001:全选 1000 个邮箱性能 - -**测试目的:** 验证全选 1000 个邮箱的性能 < 100ms - -**前置条件:** -- 前端页面已加载 -- 当前分组有 1000 个邮箱 - -**测试步骤:** -1. 准备 1000 个测试邮箱 -2. 打开浏览器开发者工具 -3. 在控制台执行性能测试代码 -4. 记录耗时 - -**测试代码:** -```javascript -console.time('全选性能'); -selectAllAccounts(); -console.timeEnd('全选性能'); -``` - -**预期结果:** 耗时 < 100ms - -**测试状态:** ⬜ 待执行 - ---- - -### 6.2 验证码提取性能测试 - -#### 测试用例 PT-002:验证码提取 API 性能 - -**测试目的:** 验证验证码提取 API 响应时间 < 2 秒 - -**前置条件:** -- 后端服务已启动 -- 测试账号已登录 - -**测试步骤:** -1. 使用 Postman 或 curl 测试 API -2. 记录响应时间 -3. 重复测试 10 次取平均值 - -**测试命令:** -```bash -time curl -X GET "http://localhost:5000/api/emails/test@example.com/extract-verification" \ - -H "Cookie: session=xxx" -``` - -**预期结果:** 平均响应时间 < 2000ms - -**测试状态:** ⬜ 待执行 - ---- - -### 6.3 轮询性能测试 - -#### 测试用例 PT-003:轮询不影响页面交互 - -**测试目的:** 验证轮询过程中页面交互流畅 - -**前置条件:** -- 前端页面已加载 -- 启用了自动轮询 - -**测试步骤:** -1. 启动轮询 -2. 在轮询过程中进行各种操作: - - 切换分组 - - 搜索邮箱 - - 点击按钮 -3. 观察页面响应 - -**预期结果:** -- 页面交互流畅,无卡顿 -- 操作响应及时 - -**测试状态:** ⬜ 待执行 - ---- - -## 7. 回归测试 - -### 7.1 现有功能回归 - -#### 测试用例 RT-001:批量移动分组功能正常 - -**测试目的:** 验证新功能不影响现有的批量移动分组功能 - -**测试步骤:** -1. 手动选中多个邮箱 -2. 点击"移动分组"按钮 -3. 选择目标分组 -4. 确认移动 - -**预期结果:** 功能正常,邮箱成功移动 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 RT-002:批量打标签功能正常 - -**测试目的:** 验证新功能不影响现有的批量打标签功能 - -**测试步骤:** -1. 手动选中多个邮箱 -2. 点击"打标签"按钮 -3. 选择标签 -4. 确认 - -**预期结果:** 功能正常,标签成功添加 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 RT-003:邮件获取功能正常 - -**测试目的:** 验证新功能不影响现有的邮件获取功能 - -**测试步骤:** -1. 选中一个邮箱 -2. 点击"获取邮件"按钮 -3. 观察邮件列表 - -**预期结果:** 功能正常,邮件列表正确显示 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 RT-004:设置功能正常 - -**测试目的:** 验证新功能不影响现有的设置功能 - -**测试步骤:** -1. 打开设置页面 -2. 修改登录密码 -3. 保存设置 - -**预期结果:** 功能正常,设置成功保存 - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 RT-005:单元测试通过 - -**测试目的:** 验证所有单元测试通过 - -**测试步骤:** -1. 运行命令:`python -m unittest discover -s tests -v` -2. 观察测试结果 - -**预期结果:** 所有测试通过,无失败用例 - -**测试状态:** ⬜ 待执行 - ---- - -## 8. 测试执行计划 - -### 8.1 测试阶段划分 - -**阶段 1:单元测试(2 天)** -- 执行 UT-001 到 UT-018 -- 修复发现的问题 -- 确保所有单元测试通过 - -**阶段 2:API 集成测试(1 天)** -- 执行 IT-001 到 IT-009 -- 验证 API 接口正确性 -- 修复发现的问题 - -**阶段 3:前端功能测试(2 天)** -- 执行 FT-001 到 FT-021 -- 验证前端交互正确性 -- 修复发现的问题 - -**阶段 4:性能测试(1 天)** -- 执行 PT-001 到 PT-003 -- 验证性能指标达标 -- 优化性能问题 - -**阶段 5:回归测试(1 天)** -- 执行 RT-001 到 RT-005 -- 确保现有功能不受影响 -- 修复回归问题 - -### 8.2 测试环境准备 - -**数据准备:** -- [ ] 创建 10 个测试邮箱账号 -- [ ] 准备 20 封测试邮件样本 -- [ ] 创建 3 个测试分组 -- [ ] 创建测试标签 - -**环境配置:** -- [ ] 配置测试数据库 -- [ ] 启动后端测试服务 -- [ ] 配置浏览器测试环境 - -### 8.3 测试报告模板 - -```markdown -# 测试报告 - -## 测试概况 -- 测试日期:YYYY-MM-DD -- 测试人员:XXX -- 测试版本:V1.0 - -## 测试结果统计 -- 总用例数:XX -- 通过数:XX -- 失败数:XX -- 阻塞数:XX -- 通过率:XX% - -## 失败用例详情 -| 用例编号 | 用例名称 | 失败原因 | 严重程度 | 状态 | -|---------|---------|---------|---------|------| -| UT-001 | XXX | XXX | 高 | 待修复 | - -## 性能测试结果 -| 测试项 | 目标值 | 实际值 | 是否达标 | -|-------|-------|-------|---------| -| 全选性能 | < 100ms | 85ms | ✅ | -| API 响应 | < 2s | 1.5s | ✅ | - -## 问题汇总 -1. 【高】XXX 问题描述 -2. 【中】XXX 问题描述 -3. 【低】XXX 问题描述 - -## 测试结论 -- [ ] 通过,可以发布 -- [ ] 不通过,需要修复问题后重新测试 -``` - ---- - -## 9. 测试总结 - -本测试文档共包含: -- **单元测试用例**:18 个(UT-001 到 UT-018) -- **API 集成测试用例**:9 个(IT-001 到 IT-009) -- **前端功能测试用例**:21 个(FT-001 到 FT-021) -- **性能测试用例**:3 个(PT-001 到 PT-003) -- **回归测试用例**:5 个(RT-001 到 RT-005) - -**总计:56 个测试用例** - -### 测试覆盖率目标 -- 后端代码覆盖率:> 80% -- 前端关键功能覆盖率:100% -- API 接口覆盖率:100% - -### 验收标准 -- 所有 P0 测试用例通过 -- 性能测试达标 -- 回归测试通过 -- 无阻塞性问题 diff --git a/docs/TEST/Outlook邮件管理工具-批量操作与自动化增强-测试文档.md b/docs/TEST/Outlook邮件管理工具-批量操作与自动化增强-测试文档.md deleted file mode 100644 index c6ff906..0000000 --- a/docs/TEST/Outlook邮件管理工具-批量操作与自动化增强-测试文档.md +++ /dev/null @@ -1,508 +0,0 @@ -# Outlook 邮件管理工具|批量操作与自动化增强 测试文档 - -- 文档状态:草案 -- 版本:V1.0 -- 日期:2026-02-24 -- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-批量操作与自动化增强PRD.md` -- 对齐 FD:`docs/FD/Outlook邮件管理工具-批量操作与自动化增强FD.md` -- 对齐 TDD:`docs/TDD/Outlook邮件管理工具-批量操作与自动化增强TDD.md` -- 对齐 TODO:`docs/TODO/Outlook邮件管理工具-批量操作与自动化增强TODO.md` - ---- - -## 1. 测试概述 - -### 1.1 测试目标 - -本测试文档旨在验证批量操作与自动化增强功能的正确性、性能和可靠性,确保: - -1. **验证码提取功能**: - - 智能识别算法准确率 > 90% - - 保底提取算法覆盖常见格式 - - 链接提取完整准确 - - API 响应时间 < 2 秒 - -2. **全选功能**: - - 全选/半选/未选状态正确 - - 与批量操作无缝集成 - - 性能满足要求(1000 个邮箱 < 100ms) - -3. **自动轮询与通知功能**: - - 轮询机制稳定可靠 - - 新邮件检测准确 - - 通知显示正常 - - 配置管理正确 - -### 1.2 测试范围 - -**包含:** -- 后端验证码提取服务单元测试 -- 后端 API 接口集成测试 -- 前端全选功能测试 -- 前端复制功能测试 -- 前端轮询功能测试 -- 前端通知功能测试 -- 性能测试 -- 边界情况测试 -- 回归测试 - -**不包含:** -- 现有功能的完整回归测试(仅抽样验证) -- 安全渗透测试 -- 压力测试(超过 1000 个邮箱) - -### 1.3 测试环境 - -**后端环境:** -- Python 3.8+ -- Flask 3.0+ -- SQLite 3 -- 测试框架:unittest - -**前端环境:** -- Chrome 最新版 -- Firefox 最新版 -- Safari 最新版(可选) -- Edge 最新版(可选) - -**测试数据:** -- 测试邮箱账号:10 个 -- 测试邮件样本:20 封(覆盖不同验证码格式) -- 测试分组:3 个 - ---- - -## 2. 测试数据准备 - -### 2.1 测试邮件样本 - -#### 样本 1:中文验证码(纯数字) -``` -主题:【测试网站】验证码通知 -内容: -尊敬的用户: -您的验证码是 123456,请在10分钟内完成验证。 -如非本人操作,请忽略此邮件。 -``` - -#### 样本 2:英文验证码(纯数字) -``` -主题:Verification Code -内容: -Dear User, -Your verification code is 654321. Please verify within 10 minutes. -If this was not you, please ignore this email. -``` - -#### 样本 3:验证码(数字+字母) -``` -主题:Account Verification -内容: -Hello, -Your OTP is ABC123. This code will expire in 5 minutes. -Thank you. -``` - -#### 样本 4:验证码(大小写混合) -``` -主题:Security Code -内容: -Your security code: XyZ789 -Please enter this code to continue. -``` - -#### 样本 5:包含链接的验证邮件 -``` -主题:Verify Your Email -内容: -Please click the link below to verify your email: -https://example.com/verify?token=abc123def456 -This link will expire in 24 hours. -``` - -#### 样本 6:同时包含验证码和链n``` -主题:Complete Your Registration -内容: -Your verification code is 999888. -Or click here to verify: https://example.com/activate?code=999888 -``` - -#### 样本 7:多个链接 -``` -主题:Welcome to Our Service -内容: -Activate your account: https://example.com/activate -Visit our website: https://example.com -Read our terms: https://example.com/terms -``` - -#### 样本 8:无验证码的普通邮件 -``` -主题:Newsletter -内容: -Welcome to our newsletter! -This is a regular email without any verification code. -Thank you for subscribing. -``` - -#### 样本 9:包含日期和时间(干扰项) -``` -主题:Meeting Reminder -内容: -Meeting scheduled for 2024-01-15 at 14:30. -Please confirm your attendance. -``` - -#### 样本 10:空邮件 -``` -主题:Test Empty Email -内容: -(空内容) -``` - -#### 样本 11:HTML 格式邮件 -```html -主题:Verification Required -内容: - - -

Your verification code is 777666

-

Click here to verify.

- - -``` - -#### 样本 12:验证码在关键词前面 -``` -主题:Code Notification -内容: -888999 is your verification code. -Please use it to complete the process. -``` - -#### 样本 13:多个验证码(取第一个) -``` -主题:Multiple Codes -内容: -Your code is 111222. -Previous code 333444 has expired. -``` - -#### 样本 14:验证码包含空格 -``` -主题:Verification -内容: -Your code: 555 666 -Please enter without spaces. -``` - -#### 样本 15:验证码包含连字符 -``` -主题:Access Code -内容: -Your access code is 777-888. -Valid for 15 minutes. -``` - -### 2.2 测试账号数据 - -```python -# 测试账号列表 -TEST_ACCOUNTS = [ - { - "id": 1, - "email": "test1@example.com", - "group_id": 1, - "remark": "测试账号1" - }, - { - "id": 2, - "email": "test2@example.com", - "group_id": 1, - "remark": "测试账号2" - }, - # ... 共 10 个测试账号 -] -``` - -### 2.3 测试分组数据 - -```python -# 测试分组列表 -TEST_GROUPS = [ - {"id": 1, "name": "测试分组1", "color": "#409eff"}, - {"id": 2, "name": "测试分组2", "color": "#67c23a"}, - {"id": 3, "name": "测试分组3", "color": "#e6a23c"} -] -``` - ---- - -## 3. 单元测试用例(后端) - -### 3.1 验证码提取算法测试 - -#### 测试用例 UT-001:智能识别 - 中文关键词 + 纯数字验证码 - -**测试目的:** 验证智能识别算法能正确识别中文关键词附近的纯数字验证码 - -**前置条件:** 验证码提取服务已实现 - -**测试步骤:** -1. 准备测试邮件内容(样本 1) -2. 调用 `smart_extract_verification_code(content)` -3. 验证返回结果 - -**预期结果:** 返回 `"123456"` - -**测试代码:** -```python -def test_smart_extract_chinese_keyword_pure_number(self): - content = "您的验证码是 123456,请在10分钟内完成验证。" - result = smart_extract_verification_code(content) - self.assertEqual(result, "123456") -``` - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 UT-002:智能识别 - 英文关键词 + 纯数字验证码 - -**测试目的:** 验证智能识别算法能正确识别英文关键词附近的纯数字验证码 - -**前置条件:** 验证码提取服务已实现 - -**测试步骤:** -1. 准备测试邮件内容(样本 2) -2. 调用 `smart_extract_verification_code(content)` -3. 验证返回结果 - -**预期结果:** 返回 `"654321"` - -**测试代码:** -```python -def test_smart_extract_english_keyword_pure_number(self): - content = "Your verification code is 654321. Please verify within 10 minutes." - result = smart_extract_verification_code(content) - self.assertEqual(result, "654321") -``` - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 UT-003:智能识别 - OTP 关键词 + 数字字母混合 - -**测试目的:** 验证智能识别算法能正确识别 OTP 关键词附近的数字字母混合验证码 - -**前置条件:** 验证码提取服务已实现 - -**测试步骤:** -1. 准备测试邮件内容(样本 3) -2. 调用 `smart_extract_verification_code(content)` -3. 验证返回结果 - -**预期结果:** 返回 `"ABC123"` - -**测试代码:** -```python -def test_smart_extract_otp_keyword_alphanumeric(self): - content = "Your OTP is ABC123. This code will expire in 5 minutes." - result = smart_extract_verification_code(content) - self.assertEqual(result, "ABC123") -``` - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 UT-004:智能识别 - 验证码在关键词前面 - -**测试目的:** 验证智能识别算法能识别验证码在关键词前面的情况 - -**前置条件:** 验证码提取服务已实现 - -**测试步骤:** -1. 准备测试邮件内容(样本 12) -2. 调用 `smart_extract_verification_code(content)` -3. 验证返回结果 - -**预期结果:** 返回 `"888999"` - -**测试代码:** -```python -def test_smart_extract_code_before_keyword(self): - content = "888999 is your verification code." - result = smart_extract_verification_code(content) - self.assertEqual(result, "888999") -``` - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 UT-005:保底提取 - 无关键词的验证码 - -**测试目的:** 验证保底提取算法能在没有关键词的情况下提取验证码 - -**前置条件:** 验证码提取服务已实现 - -**测试步骤:** -1. 准备测试邮件内容(无关键词但包含验证码) -2. 调用 `fallback_extract_verification_code(content)` -3. 验证返回结果 - -**预期结果:** 返回验证码 - -**测试代码:** -```python -def test_fallback_extract_without_keyword(self): - content = "Please use code XYZ789 to complete your registration." - result = fallback_extract_verification_code(content) - self.assertEqual(result, "XYZ789") -``` - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 UT-006:保底提取 - 过滤日期格式 - -**测试目的:** 验证保底提取算法能正确过滤日期格式(避免误判) - -**前置条件:** 验证码提取服务已实现 - -**测试步骤:** -1. 准备包含日期的测试邮件内容(样本 9) -2. 调用 `fallback_extract_verification_code(content)` -3. 验证返回结果 - -**预期结果:** 不返回日期(2024),返回 None 或其他验证码 - -**测试代码:** -```python -def test_fallback_extract_filter_date(self): - content = "Meeting scheduled for 2024-01-15 at 14:30." - result = fallback_extract_verification_code(content) - # 2024 应该被过滤掉 - self.assertNotEqual(result, "2024") -``` - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 UT-007:保底提取 - 过滤时间格式 - -**测试目的:** 验证保底提取算法能正确过滤时间格式(避免误判) - -**前置条件:** 验证码提取服务已实现 - -**测试步骤:** -1. 准备包含时间的测试邮件内容 -2. 调用 `fallback_extract_verification_code(content)` -3. 验证返回结果 - -**预期结果:** 不返回时间(1430),返回 None 或其他验证码 - -**测试代码:** -```python -def test_fallback_extract_filter_time(self): - content = "Meeting at 1430 hours." - result = fallback_extract_verification_code(content) - # 1430 应该被过滤掉(14:30) - self.assertNotEqual(result, "1430") -``` - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 UT-008:链接提取 - 单个链接 - -**测试目的:** 验证链接提取算法能正确提取单个 HTTP/HTTPS 链接 - -**前置条件:** 验证码提取服务已实现 - -**测试步骤:** -1. 准备包含单个链接的测试邮件内容(样本 5) -2. 调用 `extract_links(content)` -3. 验证返回结果 - -**预期结果:** 返回 `["https://example.com/verify?token=abc123def456"]` - -**测试代码:** -```python -def test_extract_single_link(self): - content = "Please click: https://example.com/verify?token=abc123def456" - result = extract_links(content) - self.assertEqual(len(result), 1) - self.assertEqual(result[0], "https://example.com/verify?token=abc123def456") -``` - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 UT-009:链接提取 - 多个链接 - -**测试目的:** 验证链接提取算法能正确提取多个链接并去重 - -**前置条件:** 验证码提取服务已实现 - -**测试步骤:** -1. 准备包含多个链接的测试邮件内容(样本 7) -2. 调用 `extract_links(content)` -3. 验证返回结果 - -**预期结果:** 返回 3 个不同的链接 - -**测试代码:** -```python -def test_extract_multiple_links(self): - content = """ - Activate: https://example.com/activate - Visit: https://example.com - Terms: https://example.com/terms - """ - result = extract_links(content) - self.assertEqual(len(result), 3) - self.assertIn("https://example.com/activate", result) - self.assertIn("https://example.com", result) - self.assertIn("https://example.com/terms", result) -``` - -**测试状态:** ⬜ 待执行 - ---- - -#### 测试用例 UT-010:链接提取 - 链接去重 - -**测试目的:** 验证链接提取算法能正确去重重复的链接 - -**前置条件:** 验证码提取服务已实现 - -**测试步骤:** -1. 准备包含重复链接的测试邮件内容 -2. 调用 `extract_links(content)` -3. 验证返回结果 - -**预期结果:** 返回去重后的链接列表 - -**测试代码:** -```python -def test_extract_links_deduplication(self): - content = """ - Link1: https://example.com/verify - Link2: https://example.com/verify - Link3: https://example.com/help - """ - result = extract_links(content) - self.assertEqual(len(result), 2) # 去重后只有 2 个 -``` - -**测试状态:** ⬜ 待执行 - ---- - diff --git a/docs/TODO/Outlook邮件管理工具-Legacy迁移到Controllers层TODO.md b/docs/TODO/Outlook邮件管理工具-Legacy迁移到Controllers层TODO.md deleted file mode 100644 index d5a5caf..0000000 --- a/docs/TODO/Outlook邮件管理工具-Legacy迁移到Controllers层TODO.md +++ /dev/null @@ -1,637 +0,0 @@ -# Outlook 邮件管理工具|Legacy 迁移到 Controllers 层 TODO(待办清单) - -- 文档状态:草案 -- 版本:V1.0 -- 日期:2026-02-24 -- 对齐 PRD:`docs/PRD/PRD-00003-Legacy代码拆分到Controllers层.md` -- 对齐 FD:`docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md` -- 对齐 TDD:`docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md` - ---- - -## P0(必须完成) - -### 阶段 0:文档与基线准备 - -#### 0.1 文档准备 -- [x] PRD 文档已完成 -- [x] FD 文档已完成 -- [x] TDD 文档已完成 -- [ ] 阅读并理解所有文档 -- [ ] 明确验收标准和成功指标 - -#### 0.2 环境准备 -- [ ] 确认开发环境正常(Python 3.x, Flask, SQLite) -- [ ] 确认所有依赖已安装:`pip install -r requirements.txt` -- [ ] 确认数据库正常:`data/outlook_accounts.db` 存在且可访问 - -#### 0.3 基线测试 -- [ ] 运行所有现有测试:`python -m unittest discover -s tests -v` -- [ ] 确认所有测试通过(记录通过的测试数量) -- [ ] 手动验证核心功能正常: - - [ ] 登录功能 - - [ ] 分组管理 - - [ ] 账号管理 - - [ ] 邮件读取 - - [ ] Token 刷新 - -#### 0.4 Git 准备 -- [ ] 创建功能分支:`git checkout -b feature/migrate-to-controllers` -- [ ] 确认当前分支干净:`git status` -- [ ] 记录当前 commit hash(用于回滚) - ---- - -### 阶段 1:基础模块迁移(低复杂度,无依赖) - -**目标:** 迁移 groups, tags, settings, system, audit, pages 模块(共 19 个路由) - -**预计时间:** 2-3 天 - -#### 1.1 创建 Controllers 目录结构 - -- [ ] 创建 `outlook_web/controllers/` 目录 -- [ ] 创建 `outlook_web/controllers/__init__.py` 文件 -- [ ] 在 `__init__.py` 中添加模块导出(如果需要) - -#### 1.2 迁移 Groups 模块(6 个路由) - -**文件:** `outlook_web/controllers/groups.py` - -- [ ] 创建 `controllers/groups.py` 文件 -- [ ] 从 `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)` - 导出分组账号 -- [ ] 调整导入语句: - - [ ] 导入 `flask` 相关模块 - - [ ] 导入 `outlook_web.security.auth` - - [ ] 导入 `outlook_web.repositories.groups` - - [ ] 导入 `outlook_web.errors` -- [ ] 更新 `routes/groups.py`: - - [ ] 移除 `impl` 参数 - - [ ] 导入 `controllers.groups` - - [ ] 更新所有路由注册 -- [ ] 更新 `app.py`: - - [ ] 移除 `groups.ueprint(impl=legacy)` - - [ ] 改为 `groups.create_blueprint()` -- [ ] 测试验证: - - [ ] 运行单元测试:`python -m unittest tests.test_smoke_contract -v` - - [ ] 手动测试所有 6 个 API: - - [ ] GET /api/groups - - [ ] GET /api/groups/ - - [ ] POST /api/groups - - [ ] PUT /api/groups/ - - [ ] DELETE /api/groups/ - - [ ] GET /api/groups//export -- [ ] 提交 Git:`git commit -m "feat: 迁移 groups 模块到 controllers 层"` - -#### 1.3 迁移 Tags 模块(4 个路由) - -**文件:** `outlook_web/controllers/tags.py` - -- [ ] 创建 `controllers/tags.py` 文件 -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `api_get_tags()` - 获取所有标签 - - [ ] `api_add_tag()` - 添加标签 - - [ ] `api_delete_tag(tag_id)` - 删除标签 - - [ ] `api_batch_manage_tags()` - 批量管理标签 -- [ ] 调整导入语句 -- [ ] 更新 `routes/tags.py` -- [ ] 更新 `app.py` -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试所有 4 个 API -- [ ] 提交 Git:`git commit -m "feat: 迁移 tags 模块到 controllers 层"` - -#### 1.4 迁移 Settings 模块(3 个路由) - -**文件:** `outlook_web/controllers/settings.py` - -- [ ] 创建 `controllers/settings.py` 文件 -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `api_get_settings()` - 获取系统设置 - - [ ] `api_update_settings()` - 更新系统设置 - - [ ] `api_validate_cron()` - 验证 Cron 表达式 -- [ ] 调整导入语句 -- [ ] 更新 `routes/settings.py` -- [ ] 更新 `app.py` -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试所有 3 个 API -- [ ] 提交 Git:`git commit -m "feat: 迁移 settings 模块到 controllers 层"` - -#### 1.5 迁移 System 模块(3 个路由) - -**文件:** `outlook_web/controllers/system.py` - -- [ ] 创建 `controllers/system.py` 文件 -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `api_health_check()` - 健康检查 - - [ ] `api_get_diagnostics()` - 获取诊断信息 - - [ ] `api_get_upgrade_status()` - 获取升级状态 -- [ ] 调整导入语句 -- [ ] 更新 `routes/system.py` -- [ ] 更新 `app.py` -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试所有 3 个 API -- [ ] 提交 Git:`git commit -m "feat: 迁移 system 模块到 controllers 层"` - -#### 1.6 迁移 Audit 模块(1 个路由) - -**文件:** `outlook_web/controllers/audit.py` - -- [ ] 创建 `controllers/audit.py` 文件 -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `api_get_audit_logs()` - 获取审计日志 -- [ ] 调整导入语句 -- [ ] 更新 `routes/audit.py` -- [ ] 更新 `app.py` -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试 API -- [ ] 提交 Git:`git commit -m "feat: 迁移 audit 模块到 controllers 层"` - -#### 1.7 迁移 Pages 模块(3 个路由) - -**文件:** `outlook_web/controllers/pages.py` - -- [ ] 创建 `controllers/pages.py` 文件 -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `login()` - 登录页面 - - [ ] `logout()` - 登出 - - [ ] `index()` - 首页 -- [ ] 调整导入语句 -- [ ] 更新 `routes/pages.py` -- [ ] 更新 `app.py` -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试所有 3 个页面 -- [ ] 提交 Git:`git commit -m "feat: 迁移 pages 模块到 controllers 层"` - -#### 1.8 阶段 1 验收 - -- [ ] 运行所有测试:`python -m unittest discover -s tests -v` -- [ ] 确认所有测试通过 -- [ ] 手动回归测试: - - [ ] 登录功能正常 - - [ ] 分组 CRUD 正常 - - [ ] 标签 CRUD 正常 - - [ ] 系统设置正常 - - [ ] 审计日志正常 -- [ ] 性能测试: - - [ ] 响应时间无明显增加 - - [ ] 内存使用无明显增加 -- [ ] 代码审查: - - [ ] 代码风格一致 - - [ ] 无重复代码 - - [ ] 注释清晰 -- [ ] 合并到主分支(可选):`git merge feature/migrate-to-controllers` - ---- - -### 阶段 2:独立功能模块迁移 - -**目标:** 迁移 temp_emails, oauth, scheduler 模块(共 6 个路由) - -**预计时间:** 1-2 天 - -#### 2.1 迁移 Temp Emails 模块(3 个路由) - -**文件:** `outlook_web/controllers/temp_emails.py` - -- [ ] 创建 `controllers/temp_emails.py` 文件 -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `api_get_temp_emails()` - 获取临时邮箱列表 - - [ ] `api_generate_temp_email()` - 生成临时邮箱 - - [ ] `api_get_temp_email_messages(email_id)` - 获取临时邮箱消息 -- [ ] 调整导入语句 -- [ ] 更新 `routes/temp_emails.py` -- [ ] 更新 `app.py` -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试所有 3 个 API -- [ ] 提交 Git:`git commit -m "feat: 迁移 temp_emails 模块到 controllers 层"` - -#### 2.2 迁移 OAuth 模块(2 个路由) - -**文件:** `outlook_web/controllers/oauth.py` - -- [ ] 创建 `controllers/oauth.py` 文件 -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `api_get_oauth_auth_url()` - 获取 OAuth 授权 URL - - [ ] `api_exchange_oauth_token()` - 交换 OAuth Token -- [ ] 调整导入语句 -- [ ] 更新 `routes/oauth.py` -- [ ] 更新 `app.py` -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试 OAuth 流程 -- [ ] 提交 Git:`git commit -m "feat: 迁移 oauth 模块到 controllers 层"` - -#### 2.3 迁移 Scheduler 模块(1 个路由) - -**文件:** `outlook_web/controllers/scheduler.py` - -- [ ] 创建 `controllers/scheduler.py` 文件 -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `api_get_scheduler_status()` - 获取调度器状态 -- [ ] 调整导入语句 -- [ ] 更新 `routes/scheduler.py` -- [ ] 更新 `app.py` -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试 API -- [ ] 提交 Git:`git commit -m "feat: 迁移 scheduler 模块到 controllers 层"` - -#### 2.4 阶段 2 验收 - -- [ ] 运行所有测试:`python -m unittest discover -s tests -v` -- [ ] 确认所有测试通过 -- [ ] 手动回归测试: - - [ ] 临时邮箱功能正常 - - [ ] OAuth 授权流程正常 - - [ ] 调度器状态查询正常 -- [ ] 性能测试无异常 -- [ ] 代码审查通过 - ---- - -(文档未完,待续...) -### 阶段 3:核心复杂模块迁移 - -**目标:** 迁移 emails, accounts 模块(共 24 个路由) - -**预计时间:** 3-4 天 - -#### 3.1 迁移 Emails 模块(4 个路由) - -**文件:** `outlook_web/controllers/emails.py` - -- [ ] 创建 `controllers/emails.py` 文件 -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `api_get_emails(email_addr)` - 获取邮件列表 - - [ ] `api_get_email_detail(email_addr, message_id)` - 获取邮件详情 - - [ ] `api_delete_emails()` - 删除邮件 - - [ ] `api_extract_verification(email_addr)` - 提取验证码 -- [ ] 调整导入语句: - - [ ] 导入 `outlook_web.services.graph` - - [ ] 导入 `outlook_web.services.imap` - - [ ] 导入 `outlook_web.repositories.accounts` -- [ ] 确保回退机制正常(Graph API → IMAP) -- [ ] 更新 `routes/emails.py` -- [ ] 更新 `app.py` -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试所有 4 个 API - - [ ] 测试 Graph API 成功场景 - - [ ] 测试 IMAP 回退场景 - - [ ] 测试代理配置 -- [ ] 提交 Git:`git commit -m "feat: 迁移 emails 模块到 controllers 层"` - -#### 3.2 迁移 Accounts 模块(20 个路由) - -**文件:** `outlook_web/controllers/accounts.py` - -**注意:** 这是最复杂的模块,建议分批迁移 - -##### 3.2.1 基础 CRUD(5 个路由) - -- [ ] 创建 `controllers/accounts.py` 文件 -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `api_get_accounts()` - 获取账号列表 - - [ ] `api_get_account(account_id)` - 获取单个账号 - - [ ] `api_add_account()` - 添加账号 - - [ ] `api_update_account(account_id)` - 更新账号 - - [ ] `api_delete_account(account_id)` - 删除账号 -- [ ] 调整导入语句 -- [ ] 确保数据脱敏正常(client_id, refresh_token) -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试所有 5 个 API - - [ ] 验证数据脱敏 - -##### 3.2.2 批量操作(4 个路由) - -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `api_delete_account_by_email(email_addr)` - 按邮箱删除账号 - - [ ] `api_batch_delete_accounts()` - 批量删除账号 - - [ ] `api_batch_update_account_group()` - 批量更新分组 - - [ ] `api_search_accounts()` - 搜索账号 -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试所有 4 个 API - - [ ] 测试批量操作性能 - -##### 3.2.3 导出功能(3 个路由) - -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `api_export_all_accounts()` - 导出所有账号 - - [ ] `api_export_selected_accounts()` - 导出选中账号 - - [ ] `api_generate_export_verify_token()` - 生成导出验证 Token -- [ ] 确保导出验证机制正常 -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试所有 3 个 API - - [ ] 测试导出验证流程 - -##### 3.2.4 Token 刷新(8 个路由) - -- [ ] 从 `legacy.py` 提取以下函数: - - [ ] `api_refresh_account(account_id)` - 刷新单个账号 - - [ ] `api_refresh_all_accounts()` - 刷新所有账号 - - [ ] `api_retry_refresh_account(account_id)` - 重试刷新账号 - - [ ] `api_refresh_failed_accounts()` - 刷新失败账号 - - [ ] `api_trigger_scheduled_refresh()` - 触发定时刷新 - - [ ] `api_get_refresh_logs()` - 获取刷新日志 - - [ ] `api_get_account_refresh_logs(account_id)` - 获取账号刷新日志 - - [ ] `api_get_refresh_stats()` - 获取刷新统计 -- [ ] 确保刷新机制正常 -- [ ] 测试验证: - - [ ] 运行单元测试 - - [ ] 手动测试所有 8 个 API - - [ ] 测试刷新流程 - - [ ] 测试刷新日志 - -##### 3.2.5 更新 Routes 和 App - -- [ ] 更新 `routes/accounts.py` -- [ ] 更新 `app.py` -- [ ] 提交 Git:`git commit -m "feat: 迁移 accounts 模块到 controllers 层"` - -#### 3.3 阶段 3 验收 - -- [ ] 运行所有测试:`python -m unittest discover -s tests -v` -- [ ] 确认所有测试通过 -- [ ] 手动回归测试: - - [ ] 邮件读取功能正常 - - [ ] 邮件删除功能正常 - - [ ] 验证码提取功能正常 - - [ ] 账号 CRUD 功能正常 - - [ ] 批量操作功能正常 - - [ ] 导出功能正常 - - [ ] Token 刷新功能正常 - - [ ] 刷新日志功能正常 -- [ ] 性能测试: - - [ ] 邮件读取响应时间 < 2 秒 - - [ ] 账号列表响应时间 < 500ms - - [ ] 批量操作响应时间 < 1 秒 -- [ ] 代码审查通过 - ---- - -### 阶段 4:清理和优化 - -**目标:** 删除 legacy.py,迁移工具函数和中间件 - -**预计时间:** 1 天 - -#### 4.1 验证所有路由已迁移 - -- [x] 检查 `legacy.py` 中是否还有 `api_*` 函数(所有 api_* 已迁移) -- [x] 确认所有 54 个路由都已迁移到 controllers/ -- [x] 确认所有 routes/ 文件都已更新 -- [x] 确认 app.py 中所有 Blueprint 注册都已更新 - -#### 4.2 迁移工具函数(可选) - -**目标:** 将 legacy.py 中的工具函数迁移到 utils/ - -- [ ] 创建 `outlook_web/utils/` 目录 -- [ ] 创建 `utils/__init__.py` -- [ ] 迁移以下工具函数: - - [ ] `sanitize_input()` → `utils/sanitize.py`(已在 controllers/accounts.py 中复制) - - [ ] `decode_header_value()` → `utils/sanitize.py` - - [ ] `get_email_body()` → `utils/email_parser.py` - - [ ] `parse_account_string()` → `utils/email_parser.py`(已在 controllers/accounts.py 中复制) - - [ ] `utcnow()` → `utils/datetime.py`(已在 services/refresh.py 中复制) - - [ ] `build_proxies()` → `utils/proxy.py`(已在 services/graph.py 中复制) -- [ ] 更新所有引用这些函数的地方 -- [ ] 测试验证 -- [ ] 提交 Git:`git commit -m "refactor: 迁移工具函数到 utils 模块"` - -#### 4.3 迁移中间件(可选) - -**目标:** 将 legacy.py 中的中间件迁移到 middleware/ - -- [x] 创建 `outlook_web/middleware/` 目录 -- [x] 创建 `middleware/__init__.py` -- [x] 迁移以下中间件函数: - - [x] `ensure_trace_id()` → `middleware/trace.py` - - [x] `attach_trace_id_and_normalize_errors()` → `middleware/trace.py` - - [x] `handle_http_exception()` → `middleware/error_handler.py` - - [x] `handle_exception()` → `middleware/error_handler.py` -- [x] 更新 app.py 中的中间件注册 -- [x] 测试验证 -- [x] 提交 Git:`git commit -m "refactor: 迁移中间件到 middleware 模块"` - -#### 4.4 删除 legacy.py - -- [x] 确认所有函数都已迁移 -- [x] 备份 legacy.py(保留用于参考) -- [x] 更新所有导入 legacy 的地方 - - app.py: 移除 legacy 导入,使用 middleware 和 services/scheduler - - controllers/settings.py: 使用 services/scheduler - - web_outlook_app.py: 直接从各模块导出兼容函数 -- [x] 运行所有测试(95 个测试全部通过) -- [x] 确认应用正常启动 -- [x] 提交 Git:`git commit -m "refactor: 移除 legacy 依赖,迁移调度器到 services"` - -#### 4.5 更新文档 - -- [ ] 更新 `CLAUDE.md`: - - [ ] 更新项目架构说明 - - [ ] 更新目录结构说明 -- [ ] 更新 `docs/DEV/00002-前后端拆分-开发者指南.md`: - - [ ] 更新开发指南 - - [ ] 更新新增 API 的推荐路径 -- [ ] 更新 `README.md`(如果需要) -- [ ] 提交 Git:`git commit -m "docs: 更新文档"` - -#### 4.6 阶段 4 验收 - -- [x] 运行所有测试:`python -m unittest discover -s tests -v` -- [x] 确认所有测试通过(95 个测试) -- [ ] 手动回归测试所有功能 -- [ ] 性能测试无异常 -- [ ] 代码审查通过 -- [ ] 文档审查通过 - ---- - -## P1(重要但不紧急) - -### 优化和改进 - -#### 5.1 代码优化 - -- [ ] 检查是否有重复代码,提取公共函数 -- [ ] 检查是否有过长的函数,拆分为小函数 -- [ ] 检查是否有复杂的条件判断,简化逻辑 -- [ ] 检查是否有魔法数字,提取为常量 - -#### 5.2 测试覆盖率提升 - -- [ ] 为每个 controller 编写单元测试 -- [ ] 为关键功能编写集成测试 -- [ ] 使用 coverage 工具检查测试覆盖率 -- [ ] 目标:测试覆盖率 > 80% - -#### 5.3 性能优化 - -- [ ] 使用 profiler 工具分析性能瓶颈 -- [ ] 优化数据库查询(如果需要) -- [ ] 优化 API 响应时间(如果需要) -- [ ] 添加缓存(如果需要) - -#### 5.4 文档完善 - -- [ ] 为每个 controller 添加详细的文档字符串 -- [ ] 为每个函数添加参数说明和返回值说明 -- [ ] 编写 API 文档(如果需要) -- [ ] 编写开发者指南(如果需要) - ---- - -## P2(可选) - -### 进一步改进 - -#### 6.1 引入类型注解 - -- [ ] 为所有函数添加类型注解 -- [ ] 使用 mypy 进行类型检查 -- [ ] 修复类型错误 - -#### 6.2 引入日志系统 - -- [ ] 使用 Python logging 模块 -- [ ] 为关键操作添加日志 -- [ ] 配置日志级别和输出格式 - -#### 6.3 引入监控和告警 - -- [ ] 集成 Sentry 或其他监控工具 -- [ ] 配置错误告警 -- [ ] 配置性能监控 - ---- - -## 验收标准 - -### 功能验收 - -- [ ] 所有 54 个 API 路由都已迁移到 controllers/ -- [ ] legacy.py 已删除 -- [ ] 所有功能手动测试通过 -- [ ] 前端功能正常,无需修改 - -### 质量验收 - -- [ ] 所有单元测试通过 -- [ ] 所有集成测试通过 -- [ ] 代码审查通过 -- [ ] 性能测试通过(响应时间 < 迁移前 110%) - -### 文档验收 - -- [ ] CLAUDE.md 已更新 -- [ ] 开发者指南已更新 -- [ ] 迁移文档已归档 - ---- - -## 风险和注意事项 - -### 风险识别 - -1. **路由映射错误** - - 风险:URL 路径或 HTTP 方法错误 - - 应对:充分测试,使用自动化测试 - -2. **参数解析错误** - - 风险:参数类型或名称错误 - - 应对:保持函数签名一致,单元测试覆盖 - -3. **依赖关系错误** - - 风险:循环依赖或导入错误 - - 应对:遵循依赖方向规则,先迁移无依赖模块 - -4. **性能下降** - - 风险:响应时间增加 - - 应对:性能测试对比,优化瓶颈 - -5. **功能回归** - - 风险:某些功能不正常 - - 应对:完整的回归测试 - -### 注意事项 - -1. **每个阶段完成后提交 Git** - - 便于回滚 - - 便于代码审查 - -2. **保留 legacy.py 直到所有模块迁移完成** - - 作为参考 - - 便于回滚 - -3. **充分测试** - - 单元测试 - - 集成测试 - - 手动测试 - -4. **性能监控** - - 对比迁移前后的响应时间 - - 对比迁移前后的内存使用 - -5. **文档同步更新** - - 及时更新文档 - - 保持文档和代码一致 - ---- - -## 进度跟踪 - -### 总体进度 - -- [x] 阶段 0:文档与基线准备(100%) -- [x] 阶段 1:基础模块迁移(100%) -- [x] 阶段 2:独立功能模块迁移(100%) -- [x] 阶段 3:核心复杂模块迁移(100%) -- [x] 阶段 4:清理和优化(100%) - -### 模块迁移进度 - -- [x] groups(6/6 路由)✅ -- [x] tags(4/4 路由)✅ -- [x] settings(3/3 路由)✅ -- [x] system(3/3 路由)✅ -- [x] audit(1/1 路由)✅ -- [x] pages(3/3 路由)✅ -- [x] temp_emails(3/3 路由)✅ -- [x] oauth(2/2 路由)✅ -- [x] scheduler(1/1 路由)✅ -- [x] emails(4/4 路由)✅ -- [x] accounts(20/20 路由)✅ - -**总计:** 54/54 路由已迁移(100%) - ---- - -## 参考资料 - -- [PRD-00003-Legacy代码拆分到Controllers层](../PRD/PRD-00003-Legacy代码拆分到Controllers层.md) -- [Outlook邮件管理工具-Legacy迁移到Controllers层FD](../FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md) -- [Outlook邮件管理工具-Legacy迁移到Controllers层TDD](../TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md) -- [00002-前后端拆分-开发者指南](../DEV/00002-前后端拆分-开发者指南.md) -- [CLAUDE.md](../../CLAUDE.md) - ---- - -**文档版本:** v1.0 -**最后更新:** 2026-02-24 -**维护者:** 开发团队 diff --git a/docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md b/docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md deleted file mode 100644 index 94217c6..0000000 --- a/docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md +++ /dev/null @@ -1,77 +0,0 @@ -# Outlook 邮件管理工具|前后端拆分与模块化 TODO(待办清单,仅说明要完成什么) - -- 文档状态:草案 -- 版本:V0.1 -- 日期:2026-02-23 -- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-前后端拆分与模块化PRD.md` -- 对齐 FD:`docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md` -- 对齐 TDD:`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md` - ---- - -## P0(必须完成) - -### 0) 文档与基线准备(拆分前门禁) - -- [x] PRD/FD/TDD 已产出并互相对齐 -- [x] 明确“接口契约不变”的抽样验收口径(关键接口清单 + 预期结构):`docs/QA/前后端拆分-接口契约抽样验收口径.md` -- [x] 增加“拆分阶段 smoke tests”(应用装配/关键路由/静态资源可访问):`tests/test_smoke_contract.py` -- [x] 明确每阶段回滚点(保留旧入口/兼容层/切换方式):`docs/RUN/00002-前后端拆分-回滚点与切换方式.md` - -### 1) 后端骨架 + 入口兼容(create_app) - -- [x] 新增后端包结构(例如 `outlook_web/`)并提供 `create_app()`:`outlook_web/app.py` -- [x] 保持部署入口兼容:Gunicorn/Docker 仍可用 `web_outlook_app:app`:`web_outlook_app.py` -- [x] 控制 import-time 副作用:禁止在 import 时启动调度器/线程(迁移到 `create_app()` 受控启动):`outlook_web/legacy.py` -- [x] 拆分后单测仍可跑(`python -m unittest discover -s tests -v`)通过(36/36) - -### 2) 拆基础设施模块(低风险优先) - -- [x] 配置集中化:环境变量读取与默认值集中管理(`outlook_web/config.py`) -- [x] 错误模型集中化:统一错误结构/trace_id/默认脱敏/全局异常处理保持一致:`outlook_web/errors.py` -- [x] 安全模块化:加密/鉴权/CSRF 分离,行为保持一致(`outlook_web/security/*`) -- [x] DB 模块化:连接管理 + `init_db()`/迁移逻辑集中,行为保持一致(`outlook_web/db.py`) -- [x] 审计模块化:`log_audit` 与审计查询入口保持一致(`outlook_web/audit.py`) - -### 3) 拆 repositories(SQL)与 services(业务编排) - -- [x] repositories:accounts/groups/tags/temp_emails 等 SQL 与数据映射集中(`outlook_web/repositories/*`) -- [x] services:Graph/IMAP/GPTMail/刷新编排/删除回退聚合等逻辑集中(`outlook_web/services/*`) -- [x] 多方式回退聚合错误结构保持一致(前端展示不回归)(`outlook_web/services/email_delete.py`) -- [x] 为 service/repository 补充单元测试(优先覆盖回退聚合/脱敏/边界条件)(`tests/test_error_and_trace.py`) - -### 4) Routes 模块化(Blueprint 化,URL 不变) - -- [x] 按领域拆路由模块:system/scheduler/groups/accounts/tags/emails/oauth/settings/temp_emails -- [x] 统一注册路由(create_app 内集中 register blueprint) -- [x] URL/方法/响应结构不变(以抽样契约测试与回归清单验证) - -### 5) 前端静态资源拆分(零构建) - -- [x] 新增 `static/js` 与 `static/css` 目录 -- [x] `templates/index.html` 脚本整体迁出到 `static/js/main.js`(行为不变) -- [x] JS 再逐步按 feature 拆分(api/state/ui/features/*) -- [x] CSS 至少拆出主样式文件(避免模板内联堆叠) -- [x] 前端关键路径抽样回归(登录/分组/账号/刷新/删除/导出/审计/临时邮箱/设置) - -### 6) 文档与维护指南(面向开发者) - -- [x] README 增加“目录结构/模块职责/如何新增 API”说明 -- [x] 增加或更新开发者文档:模块边界、依赖方向、测试运行方式 -- [x] 更新回归清单与验收映射中涉及的入口说明(若存在兼容层则说明迁移路径) - ---- - -## P1(强烈建议完成) - -### 7) 清理兼容层与技术债 - -- [ ] 移除不再需要的旧入口/旧函数 re-export(确保无外部依赖后再删) -- [x] 替换 `from outlook_web.legacy import *` 为 `__getattr__` 代理(避免命名污染/覆盖 `app`) -- [ ] 收敛跨模块全局变量(逐步减少 `window.*` 与隐式依赖) -- [x] 为模块依赖加门禁(避免 routes/repo 循环依赖再次出现)(`tests/test_module_boundaries.py`) - -### 8) 模板与 UI 结构进一步瘦身 - -- [x] 把 `templates/index.html` 按区域拆为 Jinja partials(降低冲突、提升可读性)(`templates/partials/*`) -- [ ] 通用 UI(modal/toast)标准化,减少 feature 之间复制粘贴 diff --git a/docs/TODO/Outlook邮件管理工具-批量操作与自动化增强TODO.md b/docs/TODO/Outlook邮件管理工具-批量操作与自动化增强TODO.md deleted file mode 100644 index 9c56ea5..0000000 --- a/docs/TODO/Outlook邮件管理工具-批量操作与自动化增强TODO.md +++ /dev/null @@ -1,463 +0,0 @@ -# Outlook 邮件管理工具|批量操作与自动化增强 TODO(待办清单,仅说明要完成什么) - -- 文档状态:草案 -- 版本:V1.0 -- 日期:2026-02-24 -- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-批量操作与自动化增强PRD.md` -- 对齐 FD:`docs/FD/Outlook邮件管理工具-批量操作与自动化增强FD.md` -- 对齐 TDD:`docs/TDD/Outlook邮件管理工具-批量操作与自动化增强TDD.md` - ---- - -## P0(必须完成) - -### 阶段 0:文档与基线准备 - -- [x] PRD/FD/TDD 已产出并互相对齐 -- [ ] 明确验收标准:功能验收清单(27 项)+ 非功能验收(5 项)+ 回归验收(5 项) -- [ ] 准备测试邮件样本(至少 10 封,覆盖不同验证码格式) -- [ ] 确认现有单测通过:`python -m unittest discover -s tests -v` - ---- - -### 阶段 1:后端基础(验证码提取服务 + API) - -#### 1.1 验证码提取服务 - -- [ ] 创建验证码提取服务模块:`outlook_web/services/verification_extractor.py` -- [ ] 实现智能验证码识别算法: - - [ ] 定义关键词列表(中英文) - - [ ] 实现上下文搜索(关键词前后 50 字符) - - [ ] 实现验证码模式匹配(4-8 位数字/字母) - - [ ] 实现过滤规则(必须包含数字) -- [ ] 实现保底验证码提取算法: - - [ ] 提取所有 4-8 位数字/字母组合 - - [ ] 过滤日期格式(1900-2100) - - [ ] 过滤时间格式(00:00-23:59) -- [ ] 实现链接提取算法: - - [ ] 使用正则表达式提取 HTTP/HTTPS 链接 - - [ ] 去重并保持顺序 -- [ ] 实现邮件内容提取: - - [ ] HTML 转纯文本(HTMLParser) - - [ ] HTML 实体解码 - - [ ] 优先级处理(body > body_html > body_preview) -- [ ] 实现获取最新邮件: - - [ ] 同时从收件箱和垃圾邮件获取 - - [ ] 按时间排序取最新 - - [ ] 复用现有的邮件获取 API - -#### 1.2 验证码提取 API - -- [ ] 在 `outlook_web/routes/emails.py` 添加路由: - - [ ] 路由:`GET /api/emails//extract-verification` - - [ ] 方法:GET - - [ ] 鉴权:`@login_required` -- [ ] 在 `outlook_web/legacy.py` 实现 view 函数: - - [ ] 参数验证(邮箱格式) - - [ ] 调用验证码提取服务 - - [ ] 返回标准响应结构 - - [ ] 错误处理(404/401/500) -- [ ] 错误处理策略: - - [ ] 邮箱不存在 → 404 - - [ ] 未找到邮件 → 404 - - [ ] 未找到验证信息 → 404 - - [ ] Token 过期 → 401 - - [ ] 网络错误 → 500 - -#### 1.3 单元测试 - -- [ ] 创建测试文件:`tests/test_verification_extractor.py` -- [ ] 测试智能识别: - - [ ] 中文关键词识别 - - [ ] 英文关键词识别 - - [ ] 多种关键词组合 -- [ ] 测试保底提取: - - [ ] 纯数字验证码 - - [ ] 数字+字母验证码 - - [ ] 过滤日期和时间 -- [ ] 测试链接提取: - - [ ] 单个链接 - - [ ] 多个链接 - - [ ] 链接去重 -- [ ] 测试边界情况: - - [ ] 邮件内容为空 - - [ ] 未找到验证码 - - [ ] 未找到链接 -- [ ] 测试 API 接口: - - [ ] 成功场景(有验证码和链接) - - [ ] 成功场景(只有验证码) - - [ ] 成功场景(只有链接) - - [ ] 失败场景(未找到验证信息) - - [ ] 失败场景(邮箱不存在) - - [ ] 失败场景(未登录) - ---- - -### 阶段 2:后端配置(轮询设置) - -#### 2.1 数据库变更 - -- [ ] 在 `outlook_web/db.py` 的 `init_db()` 函数中添加: - - [ ] 插入 `enable_auto_polling` 配置(默认:`false`) - - [ ] 插入 `polling_interval` 配置(默认:`10`) - - [ ] 插入 `polling_count` 配置(默认:`5`) - - [ ] 使用 `INSERT OR IGNORE` 避免重复插入 - -#### 2.2 设置 API 更新 - -- [ ] 更新 `GET /api/settings` 响应: - - [ ] 添加 `enable_auto_polling` 字段 - - [ ] 添加 `polling_interval` 字段 - - [ ] 添加 `polling_count` 字段 - - [ ] 类型转换(string → boolean/integer) -- [ ] 更新 `PUT /api/settings` 请求处理: - - [ ] 处理 `enable_auto_polling` 更新 - - [ ] 处理 `polling_interval` 更新(验证范围:5-300) - - [ ] 处理 `polling_count` 更新(验证范围:1-100) - - [ ] 参数验证和错误提示 - -#### 2.3 测试 - -- [ ] 测试数据库初始化: - - [ ] 验证配置项已插入 - - [ ] 验证默认值正确 -- [ ] 测试设置 API: - - [ ] 测试读取轮询配置 - - [ ] 测试更新轮询配置 - - [ ] 测试参数验证(超出范围) - - [ ] 测试类型转换 - ---- - -### 阶段 3:前端基础(全选 + 复制按钮) - -#### 3.1 全选功能 - -- [ ] 在 `static/js/features/accounts.js` 或 `static/js/main.js` 中添加: - - [ ] 定义全选状态枚举(NONE/PARTIAL/ALL) - - [ ] 实现 `selectAllAccounts()` 函数 - - [ ] 实现 `unselectAllAccounts()` 函数 - - [ ] 实现 `getSelectAllState()` 函数 - - [ ] 实现 `updateSelectAllCheckbox()` 函数 - - [ ] 实现 `updateAccountCheckboxes()` 函数(批量更新) -- [ ] 在 `templates/index.html` 或 `templates/partials/` 中添加: - - [ ] 全选复选框 HTML 结构(排序按钮右边) - - [ ] 绑定点击事件 -- [ ] 在 `static/css/main.css` 中添加: - - [ ] 全选复选框样式 - - [ ] 半选状态样式(indeterminate) -- [ ] 集成现有批量操作: - - [ ] 确保全选后可以批量移动分组 - - [ ] 确保全选后可以批量打标签/去标签 -- [ ] 切换分组时重置全选状态 - -#### 3.2 复制按钮 - -- [ ] 在 `static/js/features/accounts.js` 中添加: - - [ ] 实现 `copyVerificationInfo(email)` 函数 - - [ ] 实现 `copyToClipboard(text)` 函数(Clipboard API + 降级) - - [ ] 处理加载状态(禁用按钮、显示动画) - - [ ] 处理成功状态(显示对勾、1 秒后恢复) - - [ ] 处理失败状态(显示错误提示) - - [ ] 处理无数据情况(自动触发获取邮件) -- [ ] 在 `renderAccountList()` 函数中添加: - - [ ] 复制按钮 HTML 结构(账号名称右边) - - [ ] 绑定点击事件 -- [ ] 在 `static/css/main.css` 中添加: - - [ ] 复制按钮样式(默认/悬停/禁用/加载/成功) - - [ ] 加载动画(spin) - -#### 3.3 测试 - -- [ ] 手动测试全选功能: - - [ ] 点击全选复选框,所有邮箱被选中 - - [ ] 再次点击,所有邮箱取消选中 - - [ ] 手动选中部分邮箱,全选框显示半选状态 - - [ ] 手动选中所有邮箱,全选框显示全选状态 - - [ ] 切换分组,全选状态重置 -- [ ] 手动测试复制功能: - - [ ] 点击复制按钮,成功复制验证码和链接 - - [ ] 显示"已复制"通知 - - [ ] 按钮显示绿色对勾(1 秒后恢复) - - [ ] 未找到验证信息时显示错误提示 - - [ ] 邮箱无数据时自动获取邮件 -- [ ] 性能测试: - - [ ] 全选 1000 个邮箱,耗时 < 100ms - ---- - -### 阶段 4:前端轮询与通知 - -#### 4.1 设置界面 - -- [ ] 在 `templates/index.html` 或设置模态框中添加: - - [ ] 轮询配置 HTML 结构(开关、间隔、次数) - - [ ] 输入框验证(min/max) -- [ ] 在 `static/js/main.js` 或 `static/js/features/settings.js` 中: - - [ ] 更新 `loadSet加载轮询配置 - - [ ] 更新 `saveSettings()` 函数,保存轮询配置 - - [ ] 更新全局变量(isPollingEnabled/pollingInterval/pollingMaxCount) -- [ ] 在 `static/css/main.css` 中添加: - - [ ] 设置项样式 - - [ ] 提示文字样式 - -#### 4.2 轮询逻辑 - -- [ ] 在 `static/js/main.js` 或新建 `static/js/features/polling.js` 中: - - [ ] 定义全局变量(pollingTimer/pollingCount/lastEmailIds 等) - - [ ] 实现 `startPolling(email)` 函数 - - [ ] 实现 `stopPolling()` 函数 - - [ ] 实现 `pollForNewEmails()` 函数 - - [ ] 实现 `detectNewEmails(oldEmailIds, newEmails)` 函数 - - [ ] 实现 `getCurrentEmails()` 函数 -- [ ] 在 `selectAccount()` 函数中: - - [ ] 调用 `startPolling(email)` 启[ ] 在 `loadAccountsByGroup()` 函数中: - - [ ] 调用 `stopPolling()` 停止轮询 -- [ ] 在页面卸载时: - - [ ] 监听 `beforeunload` 事件,调用 `stopPolling()` - -#### 4.3 通知组件 - -- [ ] 在 `static/js/main.js` 或新建 `static/js/features/notification.js` 中: - - [ ] 实现 `showNewEmailNotification(email, newEmails)` 函数 - - [ ] 实现 `showNewEmailBadge(email)` 函数 - - [ ] 实现 `hideNewEmailBadge(email)` 函数 -- [ ] 在 `static/css/main.css` 中添加: - - [ ] 通知组件样式(固定定位、滑入动画) - - [ ] 红点样式(脉冲动画) - -#### 4.4 测试 - -- [ ] 手动测试设置界面: - - [ ] 加载设置,显示当前轮询配置 - - [ ] 修改配置,保存成功 - - [ ] 参数验证(超出范围时显示错误) -- [ ] 手动测试轮询功能: - - [ ] 启用轮询,选中邮箱,自动开始轮询 - - [ ] 轮询到达次数后自动停止 - - [ ] 切换邮箱,停止当前轮询 - - [ ] 关闭页面,停止所有轮询 -- [ ] 手动测试新邮件检测: - - [ ] 发送测试邮件,轮询检测到新邮件 - - [ ] 显示页面通知(邮箱地址 + 邮件主题) - - [ ] 显示红点提示 - - [ ] 点击邮箱查看邮件,红点消失 -- [ ] 手动测试通知组件: - - [ ] 通知从右侧滑入 - - [ ] 5 秒后自动消失 - - [ ] 点击关闭按钮,立即消失 - - [ ] 多个通知堆叠显示 - ---- - -## P1(强烈建议完成) - -### 阶段 5:优化与完善 - -#### 5.1 性能优化 - -- [ ] 全选功能性能优化: - - [ ] 使用 `requestAnimationFrame` 优化渲染 - - [ ] 使用 `DocumentFragment` 减少重排重绘 - - [ ] 验证 1000 个邮箱全选耗时 < 100ms -- [ ] 验证码提取性能优化: - - [ ] 优化正则表达式 - - [ ] 缓存邮件内容 - - [ ] 验证 API 响应时间 < 2 秒 -- [ ] 轮询性能优化: - - [ ] 使用异步请求 - - [ ] 避免阻塞主线程 - - [ ] 验证轮询不影响页面交互 - -#### 5.2 用户体验优化 - -- [ ] 复制按钮优化: - - [ ] 添加 tooltip 提示 - - [ ] 优化加载动画 - - [ ] 优化成功/失败反馈 -- [ ] 通知组件优化: - - [ ] 支持多条通知队列 - - [ ] 优化滑入/滑出动画 - - [ ] 支持点击通知跳转到邮件 -- [ ] 设置界面优化: - - [ ] 添加配置说明和建议值 - - [ ] 添加实时预览(下次轮询时间) - - [ ] 优化表单验证提示 - -#### 5.3 错误处理完善 - -- [ ] 验证码提取错误处理: - - [ ] 完善错误信息(更具体的失败原因) - - [ ] 添加重试机制 - - [ ] 添加错误日志 -- [ ] 轮询错误处理: - - [ ] 区分不同类型的错误(Token 过期/网络错误等) - - [ ] 添加错误恢复机制 - - [ ] 添加错误统计 -- [ ] 剪贴板错误处理: - - [ ] 完善降级方案 - - [ ] 添加权限请求提示 - - [ ] 添加 HTTPS 要求提示 - -#### 5.4 测试完善 - -- [ ] 补充单元测试: - - [ ] 验证码提取边界情况 - - [ ] 链接提取边界情况 - - [ ] 新邮件检测边界情况 -- [ ] 补充集成测试: - - [ ] API 接口完整测试 - - [ ] 设置 API 完整测试 -- [ ] 补充性能测试: - - [ ] 全选性能测试(1000 个邮箱) - - [ ] 验证码提取性能测试 - - [ ] 轮询性能测试 - ---- - -## P2(可选优化) - -### 阶段 6:高级功能 - -#### 6.1 验证码识别增强 - -- [ ] 支持更多语言的关键词(日语、韩语等) -- [ ] 支持图片验证码识别(OCR) -- [ ] 支持自定义提取规则(正则表达式) -- [ ] 支持验证码格式学习(机器学习) - -#### 6.2 轮询功能增强 - -- [ ] 支持多邮箱同时轮询 -- [ ] 支持浏览器通知(需要用户授权) -- [ ] 支持声音提示 -- [ ] 支持轮询历史记录 - -#### 6.3 复制功能增强 - -- [ ] 支持验证码自动填充(浏览器插件) -- [ ] 支持复制历史记录 -- [ ] 支持自定义复制格式 -- [ ] 支持批量复制(多个邮箱) - -#### 6.4 性能优化 - -- [ ] 实现虚拟滚动(邮箱数量 > 1000) -- [ ] 使用 Web Worker 处理验证码提取 -- [ ] 使用 IndexedDB 缓存邮件数据 -- [ ] 使用 Service Worker 实现离线功能 - ---- - -## 验收清单(必须全部通过) - -### 功能验收 - -#### 全选功能(7 项) -- [ ] 全选复选框显示在排序按钮右边 -- [ ] 点击全选复选框可以选中/取消选中当前分组下的所有邮箱 -- [ ] 全选状态与邮箱列表选中状态实时同步(全选/半选/未选) -- [ ] 全选后可以执行批量移动分组操作 -- [ ] 全选后可以执行批量打标签/去标签操作 -- [ ] 切换分组时全选状态重置 -- [ ] 搜索或筛选后,全选只影响当前显示的邮箱 - -#### 邮件信息快速复制功能(9 项) -- [ ] 复制按钮显示在邮箱名称右边 -- [ ] 点击复制按钮可以自动提取验证码和链接 -- [ ] 验证码提取成功率 > 90%(基于测试邮件样本) -- [ ] 链接提取能正确提取所有 HTTP/HTTPS 链接 -- [ ] 复制成功后显示"已复制"通知 -- [ ] 未找到验证信息时显示"未找到验证信息"提示 -- [ ] 邮箱无数据时自动触发获取邮件操作 -- [ ] 复制按钮有加载状态、成功状态、失败状态的视觉反馈 -- [ ] 后端 API `/api/emails//extract-verification` 正常工作 - -#### 自动轮询与通知功能(11 项) -- [ ] 设置中心有"自动轮询"配置项(开关、间隔、次数) -- [ ] 启用轮询后,选中邮箱时自动开始轮询 -- [ ] 轮询到达设置次数后自动停止 -- [ ] 检测到新邮件时显示页面通知(邮箱地址 + 邮件主题) -- [ ] 邮箱列表显示新邮件视觉提示(红点/角标) -- [ ] 轮询过程静默进行,不干扰用户操作 -- [ ] 轮询失败时停止并显示错误提示 -- [ ] 切换邮箱时停止当前轮询 -- [ ] 设置保存后下次选中邮箱时生效 -- [ ] 数据库 settings 表包含轮询配置项 -- [ ] 通知 5 秒后自动消失或点击关闭 - -### 非功能验收(5 项) -- [ ] 全选操作在 100ms 内完成(1000 个邮箱) -- [ ] 验证码提取 API 响应时间 < 2 秒 -- [ ] 轮询不影响页面正常使用 -- [ ] 所有操作都有明确的成功/失败反馈 -- [ ] 错误提示清晰易懂 - -### 回归验收(5 项) -- [ ] 现有批量操作功能正常(移动分组、打标签) -- [ ] 现有邮件获取功能正常 -- [ ] 现有设置功能正常 -- [ ] 单元测试通过:`python -m unittest discover -s tests -v` -- [ ] 前端页面关键路径抽样回归(登录、分组/账号、邮件查看) - ---- - -## 文档更新清单 - -### 用户文档 -- [ ] 更新 README.md,添加新功能说明 -- [ ] 添加全选功能使用说明 -- [ ] 添加复制功能使用说明 -- [ ] 添加轮询功能配置说明 -- [ ] 更新功能特性列表 -- [ ] 更新界面预览截图 - -### 开发者文档 -- [ ] 更新 API 文档,添加验证码提取接口 -- [ ] 更新数据库文档,添加轮询配置项 -- [ ] 更新前端组件文档 -- [ ] 更新开发者指南(如何新增功能) - -### 测试文档 -- [ ] 添加验证码提取测试用例 -- [ ] 添加轮询功能测试用例 -- [ ] 更新回归测试清单 - ---- - -## 风险与依赖 - -### 技术风险 -1. **验证码识别准确率**:依赖邮件格式,可能存在误判 - - 缓解措施:提供保底规则 + 用户可手动查看邮件 -2. **浏览器兼容性**:Clipboard API 在旧浏览器不支持 - - 缓解措施:提供 execCommand 降级方案 -3. **轮询性能影响**:高频轮询可能影响页面性能 - - 缓解措施:设置合理的默认值 + 使用异步请求 - -### 依赖项 -- 依赖现有的邮件获取 API(Graph API / IMAP) -- 依赖现有的批量操作功能 -- 依赖浏览器 Clipboard API - ---- - -## 实施建议 - -### 开发顺序 -1. **阶段 1**(2-3 天):后端基础(验证码提取服务 + API + 单元测试) -2. **阶段 2**(1 天):后端配置(轮询设置 + 数据库变更) -3. **阶段 3**(2-3 天):前端基础(全选 + 复制按钮 + 手动测试) -4. **阶段 4**(2-3 天):前端轮询与通知(设置界面 + 轮询逻辑 + 通知组件) -5. **阶段 5**(1-2 天):优化与完善(性能优化 + 用户体验优化 + 测试完善) - -### 总预计时间 -- P0(必须完成):8-12 天 -- P1(强烈建议):2-3 天 -- P2(可选优化):5-10 天 - -### 里程碑 -- **里程碑 1**:后端 API 完成,单元测试通过 -- **里程碑 2**:前端基础功能完成,手动测试通过 -- **里程碑 3**:轮询与通知功能完成,集成测试通过 -- **里程碑 4**:所有验收清单通过,文档更新完成 diff --git a/docs/TODO/Outlook邮件管理工具-重构升级TODO.md b/docs/TODO/Outlook邮件管理工具-重构升级TODO.md deleted file mode 100644 index d056447..0000000 --- a/docs/TODO/Outlook邮件管理工具-重构升级TODO.md +++ /dev/null @@ -1,79 +0,0 @@ -# Outlook 邮件管理工具|重构升级 TODO(待办清单,仅说明要完成什么) - -- 文档状态:草案 -- 版本:V1.0 -- 日期:2026-02-22 -- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-重构升级PRD.md` -- 对齐 FD:`docs/FD/Outlook邮件管理工具-重构升级FD.md` -- 对齐 TDD:`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` -- 完成证据:`docs/QA/P0验收对照表.md`、`docs/QA/回归验证清单.md`、`docs/RUN/运行与定时刷新验证指南.md`、`tests/` - ---- - -## P0(必须完成) - -### 1) 运行与定时刷新可靠性 - -- [x] 交付“Web 服务运行”能力:管理界面与核心接口可用 -- [x] 交付“后台刷新任务运行”能力:可在无人值守场景执行定时刷新 -- [x] 明确并交付“官方推荐运行方式”,并保证定时刷新在该方式下可用且可验证 -- [x] 定时刷新能力齐全:启用/停用、两类调度配置方式、配置校验、未来触发时间预览 -- [x] 定时刷新可验证:最近一次触发时间、触发来源标识、触发结果统计(总数/成功/失败) -- [x] 刷新冲突控制:避免不可解释的重复执行;刷新中再次触发需有明确提示或拒绝策略 - -### 2) 状态一致性(重启/扩容下仍可用) - -- [x] 导出二次验证凭证与有效期管理在重启/扩容下保持一致 -- [x] 登录失败计数、锁定状态与解锁时间在重启/扩容下保持一致 -- [x] 定时刷新最近触发信息与(如有)执行中状态在重启/扩容下保持一致 -- [x] 所有上述状态具备过期与清理策略,并可被管理员诊断查看 - -### 3) 错误与追踪一致性 - -- [x] 所有关键接口返回统一的错误结构(含错误编号、中文提示、追踪标识、可选细节) -- [x] 错误信息默认脱敏,不泄露令牌、密码等敏感内容 -- [x] 多方式回退失败时,能聚合展示各方式失败原因(中文可理解) -- [x] 未捕获异常统一处理:返回稳定错误结构,并可通过追踪标识定位 - -### 4) 数据兼容与升级保障 - -- [x] 升级后历史数据可继续使用(账号、分组、标签、设置、刷新历史、审计记录等) -- [x] 升级过程可验证:能识别升级是否完成;失败时给出明确提示与恢复建议(含追踪标识) -- [x] 升级前提供备份提示与可操作路径 - -### 5) 核心业务功能保真(不缩水且不回归) - -- [x] 登录与会话:登录/退出/修改密码/失败限制/剩余等待提示 -- [x] 分组管理:列表、增删改、颜色/描述、网络访问策略配置、系统分组保护 -- [x] 账号管理:列表、增删改查、状态启停、备注、批量导入(校验+错误定位+结果汇总)、批量移动分组 -- [x] 标签管理:标签增删、账号批量打/去标签、按标签筛选与组合筛选 -- [x] 邮件读取:三类文件夹、分页/滚动加载、详情、全屏、信任模式提示、默认安全展示 -- [x] 邮件删除:单封/批量、二次确认、失败原因分类提示、支持重试 -- [x] Token 刷新:单账号刷新、全量刷新(进度反馈)、仅刷新失败、失败重试、结果记录 -- [x] 刷新日志与统计:近半年查询、失败列表入口、统计展示、过期记录清理 -- [x] 导出:按分组/全量/选择导出、二次验证、文件命名规则、导出审计记录 -- [x] 审计:对导出/删除/修改账号/修改分组/修改设置等敏感操作可追溯 -- [x] OAuth2 辅助:授权链接生成、凭据换取、常见失败原因提示、相关配置校验提示 -- [x] 临时邮箱:生成、列表、邮件列表/详情、删除/清空、失败原因分类提示 -- [x] 设置中心:修改登录密码/临时邮箱凭据/刷新策略;变更生效时机明确;高风险项有提示 - ---- - -## P1(强烈建议完成) - -### 6) 可观测与运维友好 - -- [x] 管理员可查看系统是否“可服务/可刷新”的健康状态信息 -- [x] 关键操作(读取/刷新/删除/导出)均可关联追踪标识,便于排障 -- [x] 失败原因分类与建议动作在界面中可见且一致 - -### 7) 性能与容量基线 - -- [x] 账号规模达到 1,000 时:账号列表加载与筛选保持可用体验 -- [x] 账号规模达到 1,000 时:全量刷新持续输出进度与结果,不出现长时间无反馈 -- [x] 关键路径性能具备可验收口径(可接受时间定义与验证方式) - -### 8) 发布回归保障 - -- [x] 提供可重复执行的回归验证清单(覆盖重启后再验证) -- [x] 回归清单覆盖:登录、分组、账号、标签、邮件读取/详情/全屏/信任模式、刷新全链路、删除、导出+二次验证、审计、OAuth2、临时邮箱、设置 diff --git a/docs/TODO/TODO-可调整布局系统.md b/docs/TODO/TODO-可调整布局系统.md deleted file mode 100644 index a3e87b6..0000000 --- a/docs/TODO/TODO-可调整布局系统.md +++ /dev/null @@ -1,1393 +0,0 @@ -# TODO - 可调整布局系统 - -## 文档信息 - -- **文档编号**: TODO-00001 -- **创建日期**: 2026-02-26 -- **版本**: v1.0 -- **状态**: 进行中 -- **负责人**: 开发团队 -- **关联文档**: - - PRD-00001: PRD - 可调整布局系统 - - FD-00001: FD - 可调整布局系统 - - TDD-00001: TDD - 可调整布局系统 - - TD-00001: TD - 可调整布局系统 - -## 目录 - -1. [任务概览](#一任务概览) -2. [阶段一:基础布局改造](#二阶段一基础布局改造) -3. [阶段二:拖动调整功能](#三阶段二拖动调整功能) -4. [阶段三:折叠功能](#四阶段三折叠功能) -5. [阶段四:状态持久化](#五阶段四状态持久化) -6. [阶段五:恢复默认布局](#六阶段五恢复默认布局) -7. [阶段六:窗口适配与键盘支持](#七阶段六窗口适配与键盘支持) -8. [阶段七:测试与优化](#八阶段七测试与优化) -9. [阶段八:文档与部署](#九阶段八文档与部署) - -## 一、任务概览 - -### 1.1 总体进度 - -| 阶段 | 任务数 | 已完成 | 进行中 | 待开始 | 进度 | -|------|--------|--------|--------|--------|------| -| 阶段一:基础布局改造 | 5 | 5 | 0 | 0 | 100% | -| 阶段二:拖动调整功能 | 6 | 6 | 0 | 0 | 100% | -| 阶段三:折叠功能 | 7 | 7 | 0 | 0 | 100% | -| 阶段四:状态持久化 | 5 | 5 | 0 | 0 | 100% | -| 阶段五:恢复默认布局 | 4 | 4 | 0 | 0 | 100% | -| 阶段六:窗口适配与键盘支持 | 6 | 6 | 0 | 0 | 100% | -| 阶段七:测试与优化 | 8 | 8 | 0 | 0 | 100% | -| 阶段八:文档与部署 | 8 | 7 | 0 | 1 | 88% | -| **总计** | **49** | **48** | **0** | **1** | **98%** | - -### 1.2 优先级说明 - -- **P0(紧急)**: 核心功能,必须完成 -- **P1(高)**: 重要功能,应该完成 -- **P2(中)**: 次要功能,可以延后 -- **P3(低)**: 优化项,时间允许时完成 - -### 1.3 时间估算 - -- **总预计工时**: 60-75 人天 -- **预计完成时间**: 11-15 个工作日(按 2 人团队计算) -- **当前进度**: 98%(仅剩 TASK-08-008 验收测试需人工签署) - -## 二、阶段一:基础布局改造 - -**目标**: 修改 HTML 结构和 CSS,实现 Grid 布局和 CSS 变量系统 - -**预计时间**: 2-3 天 - -### 任务列表 - -#### TASK-01-001: 修改 HTML 结构 - -- **优先级**: P0 -- **预计工时**: 4 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: 无 - -**任务描述**: -1. 修改 `templates/index.html` -2. 为每个面板添加 `resizable-panel` 类和 `data-panel` 属性 -3. 添加 resizer 元素(带 ARIA 属性) -4. 添加折叠按钮(带 ARIA 属性) -5. 确保 HTML 结构符合 TDD 设计 - -**验收标准**: -- [ ] 所有面板都有正确的 data 属性 -- [ ] resizer 元素包含完整的 ARIA 属性 -- [ ] 折叠按钮包含完整的 ARIA 属性 -- [ ] HTML 通过 W3C 验证 - -**参考文档**: TDD 第八章 - 集成方案 - ---- - -#### TASK-01-002: 创建 layout.css 文件 - -- **优先级**: P0 -- **预计工时**: 3 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-01-001 - -**任务描述**: -1. 创建 `static/css/layout.css` -2. 定义 CSS 变量(面板宽度、最小/最大宽度、动画时长等) -3. 实现 CSS Grid 布局 -4. 添加 resizer 样式 -5. 添加折叠状态样式 -6. 添加动画过渡效果 - -**验收标准**: -- [ ] CSS 变量定义完整 -- [ ] Grid 布局正确显示 -- [ ] resizer 有 hover 效果 -- [ ] 折叠动画流畅(200ms) - -**参考文档**: TDD 第八章 - 集成方案 - ---- - -#### TASK-01-003: 修改 main.css - -- **优先级**: P0 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-01-002 - -**任务描述**: -1. 移除固定宽度样式 -2. 改为使用 CSS 变量 -3. 确保与现有样式兼容 -4. 调整 z-index 层级 - -**验收标准**: -- [ ] 移除所有固定宽度 -- [ ] 使用 CSS 变量控制宽度 -- [ ] 现有功能样式不受影响 - ---- - -#### TASK-01-004: 引入 layout.css 到页面 - -- **优先级**: P0 -- **预计工时**: 0.5 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-01-002, TASK-01-003 - -**任务描述**: -1. 在 `templates/index.html` 中引入 `layout.css` -2. 确保加载顺序正确(在 main.css 之后) - -**验收标准**: -- [ ] CSS 文件正确加载 -- [ ] 样式生效 - ---- - -#### TASK-01-005: 验证基础布局 - -- **优先级**: P0 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-01-001, TASK-01-002, TASK-01-003, TASK-01-004 - -**任务描述**: -1. 在浏览器中测试布局显示 -2. 验证 Grid 布局正确 -3. 验证 CSS 变量生效 -4. 验证现有功能不受影响 -5. 在不同浏览器中测试(Chrome、Firefox、Safari、Edge) - -**验收标准**: -- [ ] 四个面板正确显示 -- [ ] 默认宽度正确(200px、260px、380px、自适应) -- [ ] 邮件列表和详情功能正常 -- [ ] 4 个浏览器显示一致 - -**参考文档**: TD 第八章 - 兼容性测试方案 - - -## 三、阶段二:拖动调整功能 - -**目标**: 实现鼠标拖动调整面板宽度功能 - -**预计时间**: 2-3 天 - -### 任务列表 - -#### TASK-02-001: 创建 LayoutManager 类 - -- **优先级**: P0 -- **预计工时**: 4 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-01-005 - -**任务描述**: -1. 创建 `static/js/layout-manager.js` -2. 实现 LayoutManager 类基础结构 -3. 实现构造函数和初始化方法 -4. 实现 getMinWidth() 和 getMaxWidth() 方法 -5. 实现 updatePanelWidth() 方法 - -**验收标准**: -- [ ] 类结构符合 TDD 设计 -- [ ] 所有基础方法实现完整 -- [ ] 通过单元测试 - -**参考文档**: TDD 第三章 - 类与方法设计 - ---- - -#### TASK-02-002: 实现拖动事件处理 - -- **优先级**: P0 -- **预计工时**: 6 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-02-001 - -**任务描述**: -1. 实现 startResize() 方法(mousedown 事件) -2. 实现 resize() 方法(mousemove 事件) -3. 实现 stopResize() 方法(mouseup 事件) -4. 使用 requestAnimationFrame 优化性能 -5. 实现宽度计算算法(包含最小/最大限制) - -**验收标准**: -- [ ] 可以拖动 resizer 调整宽度 -- [ ] 宽度实时更新 -- [ ] 拖动流畅,无卡顿 -- [ ] 通过集成测试 TC-F1-001-P - -**参考文档**: TDD 第二章 - 核心算法设计 - ---- - -#### TASK-02-003: 实现宽度限制逻辑 - -- **优先级**: P0 -- **预计工时**: 3 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-02-002 - -**任务描述**: -1. 实现最小宽度限制(150px、180px、280px) -2. 实现最大宽度限制(400px、500px、600px) -3. 实现邮件详情面板 400px 保证逻辑 -4. 实现 calculateOtherPanelsWidth() 方法 - -**验收标准**: -- [ ] 无法拖动到最小宽度以下 -- [ ] 无法拖动到最大宽度以上 -- [ ] 邮件详情面板始终 ≥ 400px -- [ ] 通过边界测试 TC-F1-002-B, TC-F1-003-B, TC-F1-004-B - -**参考文档**: PRD 第三章 - 功能需求 - ---- - -#### TASK-02-004: 实现视觉反馈 - -- **优先级**: P1 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-02-002 - -**任务描述**: -1. 实现 resizer hover 效果(背景色变化) -2. 实现拖动时全局光标变化(col-resize) -3. 实现拖动时禁止文本选择(user-select: none) -4. 实现高亮指示器显示 - -**验收标准**: -- [ ] hover 时 resizer 有视觉反馈 -- [ ] 拖动时光标正确 -- [ ] 拖动时不会选中文本 - ---- - -#### TASK-02-005: 处理边界情况 - -- **优先级**: P1 -- **预计工时**: 3 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-02-002, TASK-02-003 - -**任务描述**: -1. 处理鼠标移出窗口的情况 -2. 处理快速拖动的情况 -3. 处理窗口大小变化时的拖动 -4. 添加错误处理和日志 - -**验收标准**: -- [ ] 鼠标移出窗口后拖动正常结束 -- [ ] 快速拖动不会出错 -- [ ] 通过负向测试 TC-F1-005-N - -**参考文档**: TDD 第七章 - 错误处理与边界情况 - ---- - -#### TASK-02-006: 性能优化 - -- **优先级**: P0 -- **预计工时**: 4 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-02-002, TASK-02-003 - -**任务描述**: -1. 使用 requestAnimationFrame 优化拖动 -2. 避免布局抖动(批量读写 DOM) -3. 使用 CSS transform 优化动画 -4. 测量并优化帧率 - -**验收标准**: -- [ ] 拖动帧率 ≥ 60fps -- [ ] 通过性能测试 TC-F1-006-PF -- [ ] Chrome DevTools 性能分析无警告 - -**参考文档**: TDD 第六章 - 性能优化策略 - ---- - -## 四、阶段三:折叠功能 - -**目标**: 实现面板折叠/展开功能 - -**预计时间**: 1-2 天 - -### 任务列表 - -#### TASK-03-001: 实现折叠/展开方法 - -- **优先级**: P0 -- **预计工时**: 3 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-02-001 - -**任务描述**: -1. 实现 collapsePanel() 方法 -2. 实现 expandPanel() 方法 -3. 实现 togglePanel() 方法 -4. 添加/移除 collapsed 类 -5. 更新 ARIA 属性(aria-expanded) - -**验收标准**: -- [ ] 可以折叠面板 -- [ ] 可以展开面板 -- [ ] ARIA 属性正确更新 -- [ ] 通过单元测试 - -**参考文档**: TDD 第三章 - 类与方法设计 - ---- - -#### TASK-03-002: 绑定折叠按钮事件 - -- **优先级**: P0 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-03-001 - -**任务描述**: -1. 在 init() 方法中绑定折叠按钮点击事件 -2. 实现按钮图标切换(← / →) -3. 实现 aria-label 更新 - -**验收标准**: -- [ ] 点击按钮可以折叠/展开 -- [ ] 按钮图标正确切换 -- [ ] 通过集成测试 TC-F2-001-P, TC-F2-002-P - ---- - -#### TASK-03-003: 实现折叠动画 - -- **优先级**: P1 -- **预计工时**: 3 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-03-001 - -**任务描述**: -1. 在 layout.css 中添加折叠动画 -2. 使用 CSS transition(200ms ease) -3. 实现宽度和 opacity 同时变化 -4. 优化动画性能(使用 transform) - -- [ ] 折叠动画流畅 -- [ ] 动画时长 200ms ± 10ms -- [ ] 无卡顿现象 -- [ ] 通过性能测试 - -**参考文档**: PRD 第三章 - 功能需求 - ---- - -#### TASK-03-004: 实现折叠指示条 - -- **优先级**: P1 -- **预计工时**: 3 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-03-001, TASK-03-003 - -**任务描述**: -1. 创建折叠指示条 HTML 结构 -2. 添加折叠指示条样式(48px 宽) -3. 显示竖排文字(面板名称) -4. 实现点击指示条展开功能 - -**验收标准**: -- [ ] 折叠后显示指示条 -- [ ] 指示条显示面板名称 -- [ ] 点击指示条可以展开 - ---- - -#### TASK-03-005: 实现折叠规则 - -- **优先级**: P0 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-03-001 - -**任务描述**: -1. 实现至少保持一个面板展开的逻辑 -2. 邮件详情面板不可折叠 -3. 添加提示信息(尝试全部折叠时) -4. 实现 getExpandedPanels() 方法 - -**验收标准**: -- [ ] 无法折叠所有面板 -- [ ] 邮件详情面板始终展开 -- [ ] 通过负向测试 TC-F2-003-N - -**参考文档**: PRD 第三章 - 功能需求 - ---- - -#### TASK-03-006: 折叠时隐藏 resizer - -- **优先级**: P0 -- **预计工时**: 1 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-03-001 - -**任务描述**: -1. 在 layout.css 中添加折叠状态下的 resizer 样式 -2. 设置 display: none 和 pointer-events: none -3. 确保折叠状态下无法拖动 - -**验收标准**: -- [ ] 折叠后 resizer 不可见 -- [ ] 折叠后无法拖动 -- [ ] 通过测试 TC-F2-004-P, TC-F2-005-N - ---- - -#### TASK-03-007: 标记自动折叠 - -- **优先级**: P1 -- **预计工时**: 1 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-03-001 - -**任务描述**: -1. 在 collapsePanel() 中添加 auto 参数 -2. 自动折叠时设置 data-autoCollapsed 属性 -3. 展开时移除该属性 -4. 用于区分用户手动折叠和自动折叠 - -**验收标准**: -- [ ] 自动折叠有标记 -- [ ] 手动折叠无标记 -- [ ] 窗口放大时自动折叠的面板会恢复 - -**参考文档**: FD 第五章 - F5 窗口尺寸适配 - - -## 五、阶段四:状态持久化 - -**目标**: 实现布局状态保存和加载功能 - -**预计时间**: 1 天 - -### 任务列表 - -#### TASK-04-001: 创建 StateManager 类 - -- **优先级**: P0 -- **预计工时**: 3 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-02-001 - -**任务描述**: -1. 创建 `static/js/state-manager.js` -2. 实现 StateManager 类基础结构 -3. 实现 save() 方法 -4. 实现 load() 方法 -5. 实现 clear() 方法 -6. 实现 validate() 方法 -7. 实现 migrate() 方法 - -**验收标准**: -- [ ] 类结构符合 TDD 设计 -- [ ] 所有方法实现完整 -- [ ] 通过单元测试 - -**参考文档**: TDD 第三章 - 类与方法设计 - ---- - -#### TASK-04-002: 实现状态保存逻辑 - -- **优先级**: P0 -- **预计工时**: 3 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-04-001 - -**任务描述**: -1. 在 LayoutManager 中集成 StateManager -2. 实现 saveState() 方法 -3. 实现防抖保存(500ms) -4. 实现 beforeunload 强制保存 -5. 保存面板宽度和折叠状态 - -**验收标准**: -- [ ] 拖动后 500ms 自动保存 -- [ ] 折叠后立即保存 -- [ ] 页面卸载时强制保存 -- [ ] 通过测试 TC-F4-001-P, TC-F4-002-P, TC-F4-005-P - -**参考文档**: FD 第四章 - F4 状态持久化 - ---- - -#### TASK-04-003: 实现状态加载逻辑 - -- **优先级**: P0 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-04-001 - -**任务描述**: -1. 实现 loadState() 方法 -2. 在 init() 中调用 loadState() -3. 应用保存的宽度到 CSS 变量 -4. 应用保存的折叠状态 - -**验收标准**: -- [ ] 页面刷新后状态恢复 -- [ ] 宽度正确恢复 -- [ ] 折叠状态正确恢复 -- [ ] 通过测试 TC-F4-003-P - -**参考文档**: FD 第四章 - F4 状态持久化 - ---- - -#### TASK-04-004: 实现多用户隔离 - -- **优先级**: P1 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-04-001 - -**任务描述**: -1. 实现 getCurrentUserId() 方法 -2. 使用 userId 作为 localStorage 键名的一部分 -3. 支持 guest 用户(未登录) -4. 测试多用户场景 - -**验收标准**: -- [ ] 不同用户的布局独立存储 -- [ ] 用户切换后布局正确 -- [ ] 通过测试 TC-F4-004-P - -**参考文档**: PRD 第三章 - 功能需求 - ---- - -#### TASK-04-005: 错误处理 - -- **优先级**: P1 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-26 -- **依赖**: TASK-04-001, TASK-04-002, TASK-04-003 - -**任务描述**: -1. 处理 localStorage 不可用的情况 -2. 处理 QuotaExceededError -3. 处理 JSON 解析错误 -4. 处理数据验证失败 -5. 添加降级策略(使用默认布局) - -**验收标准**: -- [ ] localStorage 不可用时使用默认布局 -- [ ] 数据损坏时清除并使用默认布局 -- [ ] 通过测试 TC-F4-006-N, TC-F4-007-N -- [ ] 控制台有清晰的错误提示 - -**参考文档**: TDD 第七章 - 错误处理与边界情况 - ---- - -## 六、阶段五:恢复默认布局 - -**目标**: 实现一键恢复默认布局功能 - -**预计时间**: 1 天 - -### 任务列表 - -#### TASK-05-001: 添加恢复按钮 - -- **优先级**: P1 -- **预计工时**: 1 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-01-001 - -**任务描述**: -1. 在顶部导航栏添加"恢复默认布局"按钮 -2. 添加重置图标(↻) -3. 添加 tooltip 提示 -4. 添加 ARIA 属性 - -**验收标准**: -- [ ] 按钮显示在正确位置 -- [ ] hover 时显示 tooltip -- [ ] 按钮可访问(键盘和屏幕阅读器) - -**参考文档**: PRD 第三章 - 功能需求 - ---- - -#### TASK-05-002: 实现确认对话框 - -- **优先级**: P1 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-05-001 - -**任务描述**: -1. 创建确认对话框 HTML 结构 -2. 添加对话框样式 -3. 实现对话框显示/隐藏逻辑 -4. 添加"取消"和"确定"按钮 - -**验收标准**: -- [ ] 点击按钮显示对话框 -- [ ] 对话框内容清晰 -- [ ] 可以取消或确定 -- [ ] 通过测试 TC-F3-002-N - -**参考文档**: FD 第三章 - F3 恢复默认布局 - ---- - -#### TASK-05-003: 实现恢复逻辑 - -- **优先级**: P1 -- **预计工时**: 3 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-04-001, TASK-05-002 - -**任务描述**: -1. 实现 resetLayout() 方法 -2. 恢复默认宽度(200px、260px、380px) -3. 展开所有面板 -4. 清除 localStorage 中的状态 -5. 更新 CSS 变量 - -**验收标准**: -- [ ] 宽度恢复到默认值 -- [ ] 所有面板展开 -- [ ] localStorage 被清除 -- [ ] 通过测试 TC-F3-001-P - -**参考文档**: TDD 第三章 - 类与方法设计 - ---- - -#### TASK-05-004: 实现恢复动画 - -- **优先级**: P2 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-05-003 - -**任务描述**: -1. 添加恢复动画(300ms ease) -2. 使用 CSS transition 平滑过渡 -3. 优化动画性能 - -**验收标准**: -- [ ] 恢复过程有平滑动画 -- [ ] 动画时长 300ms -- [ ] 无卡顿现象 - -**参考文档**: PRD 第三章 - 功能需求 - ---- - -## 七、阶段六:窗口适配与键盘支持 - -**目标**: 实现窗口尺寸自适应和键盘可访问性 - -**预计时间**: 2 天 - -### 任务列表 - -#### TASK-06-001: 实现窗口调整监听 - -- **优先级**: P1 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-03-001 - -**任务描述**: -1. 实现 handleWindowResize() 方法 -2. 绑定 window resize 事件 -3. 使用防抖优化(200ms) -4. 在 init() 中调用初始适配 - -**验收标准**: -- [ ] 窗口调整时触发处理 -- [ ] 防抖生效,避免频繁触发 -- [ ] 页面加载时执行初始适配 - -**参考文档**: TDD 第二章 - 核心算法设计 - ---- - -#### TASK-06-002: 实现断点自动折叠 - -- **优先级**: P1 -- **预计工时**: 3 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-06-001 - -**任务描述**: -1. 实现 < 1200px 自动折叠分组面板 -2. 实现 < 900px 自动折叠账号面板 -3. 实现 < 700px 自动折叠邮件列表面板 -4. 标记为自动折叠(data-autoCollapsed) - -**验收标准**: -- [ ] 窗口缩小时自动折叠 -- [ ] 折叠顺序正确 -- [ ] 通过测试 TC-F5-001-P, TC-F5-002-P, TC-F5-003-P - -**参考文档**: FD 第五章 - F5 窗口尺寸适配 - ---- - -#### TASK-06-003: 实现窗口放大恢复 - -- **优先级**: P1 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-06-002 - -**任务描述**: -1. 实现 restorePanelIfNeeded() 方法 -2. 窗口放大时恢复自动折叠的面板 -3. 不恢复用户手动折叠的面板 -4. 移除 data-autoCollapsed 标记 - -**验收标准**: -- [ ] 窗口放大时自动恢复 -- [ ] 手动折叠的面板不恢复 -- [ ] 通过测试 TC-F5-004-P, TC-F5-005-N - -**参考文档**: FD 第五章 - F5 窗口尺寸适配 - ---- - -#### TASK-06-004: 实现键盘聚焦 - -- **优先级**: P1 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-02-001 - -**任务描述**: -1. 为 resizer 添加 tabindex="0" -2. 实现聚焦时的视觉反馈(outline) -3. 为折叠按钮添加键盘支持 -4. 测试 Tab 键导航 - -**验收标准**: -- [ ] Tab 键可以聚焦到 resizer -- [ ] 聚焦时有明显视觉反馈 -- [ ] 通过测试 TC-F6-001-P - -**参考文档**: FD 第六章 - F6 键盘可访问性 - ---- - -#### TASK-06-005: 实现键盘调整宽度 - -- **优先级**: P1 -- **预计工时**: 3 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-06-004 - -**任务描述**: -1. 实现 handleKeyboard() 方法 -2. 绑定 keydown 事件到 resizer -3. 实现方向键调整(← / →,每次 10px) -4. 实现 Shift + 方向键快速调整(每次 50px) -5. 更新 aria-valuenow 属性 - -**验收标准**: -- [ ] 方向键可以调整宽度 -- [ ] Shift + 方向键快速调整 -- [ ] ARIA 属性实时更新 -- [ ] 通过测试 TC-F6-002-P, TC-F6-003-P - -**参考文档**: TDD 第三章 - 类与方法设计 - ---- - -#### TASK-06-006: 完善 ARIA 属性 - -- **优先级**: P1 -- **预计工时**: 2 小时 -- **负责人**: 待分配 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-06-004, TASK-06-005 - -**任务描述**: -1. 确保所有 resizer 有完整的 ARIA 属性 -2. 确保所有折叠按钮有完整的 ARIA 属性 -3. 使用屏幕阅读器测试 -4. 修复可访问性问题 - -**验收标准**: -- [ ] 所有交互元素有 ARIA 属性 -- [ ] 屏幕阅读器正确朗读 -- [ ] 通过 axe-core 检测 -- [ ] 通过测试 TC-F6-005-P - -**参考文档**: PRD 第四章 - 非功能需求 - ---- - - -## 八、阶段七:测试与优化 - -**目标**: 完成所有测试并优化性能 - -**预计时间**: 2-3 天 - -### 任务列表 - -#### TASK-07-001: 运行单元测试 - -- **优先级**: P0 -- **预计工时**: 2 小时 -- **负责人**: 测试团队 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-02-001, TASK-04-001 - -**任务描述**: -1. 运行 StateManager 单元测试 -2. 运行 LayoutManager 单元测试 -3. 修复失败的测试 -4. 确保代码覆盖率 > 80% - -**验收标准**: -- [ ] 所有单元测试通过 -- [ ] 代码覆盖率 > 80% -- [ ] 无测试警告 - -**参考文档**: tests/layout-system/README.md - ---- - -#### TASK-07-002: 运行集成测试 - -- **优先级**: P0 -- **预计工时**: 3 小时 -- **负责人**: 测试团队 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-02-27 -- **依赖**: TASK-02-006, TASK-03-007 - -**任务描述**: -1. 运行拖动调整宽度集成测试 -2. 运行面板折叠集成测试 -3. 修复失败的测试 -4. 补充缺失的测试用例 - -**验收标准**: -- [ ] 所有集成测试通过 -- [ ] 功能覆盖率 100% -- [ ] 无测试警告 - -**参考文档**: TD 第四章 - 测试用例设计 - ---- - -#### TASK-07-003: 性能测试 - -- **优先级**: P0 -- **预计工时**: 4 小时 -- **负责人**: 测试团队 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-03-01 -- **依赖**: TASK-02-006 - -**任务描述**: -1. 测试拖动帧率(目标 ≥ 60fps) -2. 测试折叠动画性能(目标 200ms) -3. 测试 localStorage 数据大小(目标 < 1KB) -4. 测试页面加载时间增加(目标 ≤ 50ms) -5. 使用 Lighthouse 进行综合性能评分 - -**验收标准**: -- [x] 拖动帧率 ≥ 55fps -- [x] 动画时长 190-210ms -- [x] localStorage < 1KB -- [x] 加载时间增加 ≤ 50ms -- [ ] Lighthouse 性能分 ≥ 90(需浏览器环境验证) - -**参考文档**: TD 第七章 - 性能测试方案 - ---- - -#### TASK-07-004: 兼容性测试 - -- **优先级**: P0 -- **预计工时**: 4 小时 -- **负责人**: 测试团队 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-03-01 -- **依赖**: TASK-07-002 - -**任务描述**: -1. 在 Chrome 90+ 上测试所有功能 -2. 在 Firefox 88+ 上测试所有功能 -3. 在 Safari 14+ 上测试所有功能 -4. 在 Edge 90+ 上测试所有功能 -5. 测试降级场景(CSS Grid 不支持、localStorage 不可用) - -**验收标准**: -- [x] 4 个浏览器所有功能正常(CSS Grid 支持已验证) -- [x] 降级场景正常工作(测试覆盖 localStorage 不可用) -- [ ] 无浏览器特定的 bug(需持续关注) - -**参考文档**: TD 第八章 - 兼容性测试方案 - ---- - -#### TASK-07-005: 可访问性测试 - -- **优先级**: P1 -- **预计工时**: 3 小时 -- **负责人**: 测试团队 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-03-01 -- **依赖**: TASK-06-006 - -**任务描述**: -1. 使用 axe-core 自动检测 -2. 使用 NVDA/JAWS 测试屏幕阅读器 -3. 测试键盘导航完整性 -4. 修复可访问性问题 - -**验收标准**: -- [x] axe-core 无违规项(ARIA 属性完整性已通过自动化测试) -- [ ] 屏幕阅读器正确朗读(需人工验证) -- [x] 所有功能可通过键盘完成 -- [x] 符合 WCAG 2.1 AA 标准(自动化检测部分) - -**参考文档**: TD 第九章 - 可访问性测试方案 - ---- - -#### TASK-07-006: 回归测试 - -- **优先级**: P0 -- **预计工时**: 4 小时 -- **负责人**: 测试团队 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-03-01 -- **依赖**: TASK-07-002 - -**任务描述**: -1. 测试邮件列表显示和滚动 -2. 测试邮件详情查看和渲染 -3. 测试账号切换功能 -4. 测试分组管理功能 -5. 测试 Token 刷新功能 -6. 测试临时邮箱功能 - -**验收标准**: -- [x] 所有现有功能正常(95 个 Python 测试全部通过) -- [x] 无回归 bug -- [x] 回归通过率 > 98% - -**参考文档**: TD 第十章 - 回归测试策略 - ---- - -#### TASK-07-007: 性能优化 - -- **优先级**: P1 -- **预计工时**: 4 小时 -- **负责人**: 开发团队 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-03-01 -- **依赖**: TASK-07-003 - -**任务描述**: -1. 根据性能测试结果优化代码 -2. 优化 CSS 动画性能 -3. 优化 JavaScript 执行效率 -4. 减少重排和重绘 -5. 优化内存占用 - -**验收标准**: -- [x] 性能指标达标(RAF 节流、CSS 变量批量更新已验证) -- [x] Chrome DevTools 无性能警告 -- [x] 内存占用合理 - -**参考文档**: TDD 第六章 - 性能优化策略 - ---- - -#### TASK-07-008: Bug 修复 - -- **优先级**: P0 -- **预计工时**: 8 小时 -- **负责人**: 开发团队 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-03-01 -- **依赖**: TASK-07-001, TASK-07-002, TASK-07-004, TASK-07-005, TASK-07-006 - -**任务描述**: -1. 修复测试中发现的所有 P0 bug -2. 修复测试中发现的所有 P1 bug -3. 记录 P2/P3 bug(可延后修复) -4. 回归测试修复的 bug - -**验收标准**: -- [x] 所有 P0 bug 已修复 -- [x] 所有 P1 bug 已修复 -- [ ] P2/P3 bug 已记录 -- [ ] 修复后的功能通过测试 - -**参考文档**: TD 第十二章 - 缺陷管理流程 - ---- - -## 九、阶段八:文档与部署 - -**目标**: 完善文档并部署到测试环境 - -**预计时间**: 1 天 - -### 任务列表 - -#### TASK-08-001: 编写 PRD 文档 - -- **优先级**: P0 -- **预计工时**: 4 小时 -- **负责人**: 产品团队 -- **状态**: ✅ 已完成 -- **依赖**: 无 - -**任务描述**: -1. 编写产品需求文档 -2. 定义功能需求和验收标准 -3. 评审并修订 - -**验收标准**: -- [x] PRD 文档完整 -- [x] 需求清晰明确 -- [x] 通过评审 - -**完成日期**: 2026-02-26 - ---- - -#### TASK-08-002: 编写 FD 文档 - -- **优先级**: P0 -- **预计工时**: 6 小时 -- **负责人**: 开发团队 -- **状态**: ✅ 已完成 -- **依赖**: TASK-08-001 - -**任务描述**: -1. 编写功能设计文档 -2. 详细设计 26 个子功能 -3. 定义验收标准 - -**验收标准**: -- [x] FD 文档完整 -- [x] 功能设计详细 -- [x] 验收标准明确 - -**完成日期**: 2026-02-26 - ---- - -#### TASK-08-003: 编写 TDD 文档 - -- **优先级**: P0 -- **预计工时**: 8 小时 -- **负责人**: 开发团队 -- **状态**: ✅ 已完成 -- **依赖**: TASK-08-002 - -**任务描述**: -1. 编写技术设计文档 -2. 设计集成方案 -3. 定义性能优化策略 - -**验收标准**: -- [x] TDD 文档完整 -- [x] 技术方案可行 -- [x] 性能策略明确 - -**完成日期**: 2026-02-26 - ---- - -#### TASK-08-004: 编写 TD 文档 - -- **优先级**: P0 -- **预计工时**: 8 小时 -- **负责人**: 测试团队 -- **状态**: ✅ 已完成 -- **依赖**: TASK-08-003 - -**任务描述**: -1. 编写测试设计文档 -2. 设计 30 个测试用例 -3. 定义测试策略和执行计划 - -**验收标准**: -- [x] TD 文档完整 -- [x] 测试用例覆盖全面 -- [x] 测试策略清晰 - -**完成日期**: 2026-02-26 - ---- - -#### TASK-08-005: 编写用户文档 - -- **优先级**: P2 -- **预计工时**: 3 小时 -- **负责人**: 产品团队 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-03-01 -- **依赖**: TASK-07-008 - -**任务描述**: -1. 编写用户使用指南 -2. 添加功能截图和动图 -3. 编写常见问题解答(FAQ) - -**验收标准**: -- [x] 用户文档清晰易懂 -- [x] 包含所有主要功能说明 -- [ ] 有截图和示例(需浏览器截图补充) - ---- - -#### TASK-08-006: 编写开发者文档 - -- **优先级**: P2 -- **预计工时**: 2 小时 -- **负责人**: 开发团队 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-03-01 -- **依赖**: TASK-07-008 - -**任务描述**: -1. 更新 README.md -2. 添加 API 文档注释 -3. 编写代码示例 - -**验收标准**: -- [x] README 包含布局系统说明 -- [x] 代码有完整注释 -- [x] 有使用示例 - ---- - -#### TASK-08-007: 部署到测试环境 - -- **优先级**: P0 -- **预计工时**: 2 小时 -- **负责人**: 运维团队 -- **状态**: ✅ 已完成 -- **完成日期**: 2026-03-01 -- **依赖**: TASK-07-008 - -**任务描述**: -1. 构建生产版本 -2. 部署到测试环境 -3. 验证部署成功 -4. 冒烟测试 - -**验收标准**: -- [x] 部署成功(应用创建验证通过,59 个路由正常注册) -- [x] 测试环境可访问 -- [x] 核心功能正常 - ---- - -#### TASK-08-008: 验收测试 - -- **优先级**: P0 -- **预计工时**: 4 小时 -- **负责人**: 产品团队 -- **状态**: ⏳ 待开始 -- **依赖**: TASK-08-007 - -**任务描述**: -1. 产品经理进行验收测试 -2. 验证所有 PRD 需求 -3. 确认验收标准达成 -4. 签署验收报告 - -**验收标准**: -- [ ] 所有 P0 需求通过验收 -- [ ] 所有 P1 需求通过验收 -- [ ] 验收报告已签署 - ---- - -## 十、风险管理 - -### 10.1 技术风险 - -| 风险 | 影响 | 概率 | 应对措施 | 负责人 | -|------|------|------|----------|--------| -| CSS Grid 兼容性问题 | 高 | 低 | 提前测试,准备 flexbox 降级方案 | 开发团队 | -| 拖动性能不达标 | 中 | 中 | 使用 requestAnimationFrame 优化 | 开发团队 | -| localStorage 不可用 | 低 | 低 | 降级到默认布局 | 开发团队 | -| 现有功能受影响 | 高 | 中 | 充分回归测试 | 测试团队 | - -### 10.2 进度风险 - -| 风险 | 影响 | 概率 | 应对措施 | 负责人 | -|------|------|------|----------|--------| -| 开发时间超期 | 中 | 中 | 优先完成 P0 功能,P2 功能可延后 | 项目经理 | -| 测试时间不足 | 高 | 中 | 提前准备测试环境和用例 | 测试团队 | -| Bug 修复时间长 | 中 | 低 | 预留 buffer 时间 | 开发团队 | - -### 10.3 资源风险 - -| 风险 | 影响 | 概率 责人 | -|------|------|------|----------|--------| -| 人员不足 | 高 | 低 | 提前协调资源 | 项目经理 | -| 技能不匹配 | 中 | 低 | 提供技术培训 | 技术负责人 | - -## 十一、里程碑 - -| 里程碑 | 预计完成日期 | 状态 | 备注 | -|--------|-------------|------|------| -| M1: 文档完成 | 2026-02-26 | ✅ 已完成 | PRD、FD、TDD、TD 已完成 | -| M2: 基础布局改造 | 2026-02-26 | ✅ 已完成 | 阶段一 | -| M3: 拖动功能完成 | 2026-02-26 | ✅ 已完成 | 阶段二 | -| M4: 折叠功能完成 | 2026-02-26 | ✅ 已完成 | 阶段三 | -| M5: 状态持久化完成 | 2026-02-26 | ✅ 已完成 | 阶段四 | -| M6: 恢复默认布局完成 | 2026-02-27 | ✅ 已完成 | 阶段五 | -| M7: 窗口适配与键盘支持完成 | 2026-02-27 | ✅ 已完成 | 阶段六 | -| M8: 测试完成 | 2026-03-14 | ⏳ 待开始 | 阶段七 | -| M9: 部署到测试环境 | 2026-05 | ⏳ 待开始 | 阶段八 | -| M10: 验收通过 | 2026-03-15 | ⏳ 待开始 | 项目完成 | - -## 十二、任务分配建议 - -### 12.1 开发团队(2 人) - -**开发人员 A**: -- 负责阶段一、二、四(基础布局、拖动、状态持久化) -- 负责性能优化 -- 预计工时:30-35 人天 - -**开发人员 B**: -- 负责阶段三、五、六(折叠、恢复、窗口适配) -- 负责可访问性实现 -- 预计工时:25-30 人天 - -### 12.2 测试团队(1 人) - -**测试人员**: -- 负责编写和执行所有测试 -- 负责 bug 跟踪和验证 -- 预计工时:15-20 人天 - -### 12.3 产品团队(1 人) - -**产品经理**: -- 负责需求澄清和验收 -- 负责用户文档编写 -- 预计工时:5-8 人天 - -## 十三、每日站会议题 - -1. 昨天完成了什么任务? -2. 今天计划完成什么任务? -3. 遇到了什么阻碍? -4. 需要什么帮助? -5. 进度是否符合预期? - -## 十四、变更管理 - -### 14.1 变更流程 - -1. 提出变更请求 -2. 评估变更影响(时间、资源、风险) -3. 项目经理审批 -4. 更新 TODO 文档 -5. 通知相关人员 - -### 14.2 变更记录 - -| 日期 | 变更内容 | 影响 | 审批人 | 状态 | -|------|---------|------|--------|------| -| 2026-02-26 | 初始版本创建 | - | 项目经理 | ✅ 已批准 | - ---- - -**文档结束** - -**最后更新**: 2026-02-27 -**下次更新**: 每日更新进度 diff --git a/static/js/features/accounts.js b/static/js/features/accounts.js index 7938984..4c10e3c 100644 --- a/static/js/features/accounts.js +++ b/static/js/features/accounts.js @@ -25,7 +25,7 @@ const folderTabs = document.getElementById('folderTabs'); if (folderTabs) { folderTabs.style.display = 'flex'; - document.querySelectorAll('.folder-tab').forEach(tab => { + document.querySelectorAll('.email-tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.folder === 'inbox'); }); } diff --git a/static/js/features/emails.js b/static/js/features/emails.js index f26c670..9339440 100644 --- a/static/js/features/emails.js +++ b/static/js/features/emails.js @@ -32,7 +32,7 @@ // 禁用按钮 const refreshBtn = document.querySelector('.refresh-btn'); - const folderTabs = document.querySelectorAll('.folder-tab'); + const folderTabs = document.querySelectorAll('.email-tab'); if (refreshBtn) { refreshBtn.disabled = true; refreshBtn.textContent = '获取中...'; diff --git a/static/js/features/groups.js b/static/js/features/groups.js index 3403829..4225754 100644 --- a/static/js/features/groups.js +++ b/static/js/features/groups.js @@ -32,6 +32,12 @@ loadAccountsByGroup(currentGroupId, true); } } + } else { + // 首次进入:自动选中第一个非临时邮箱分组 + const firstNormalGroup = groups.find(g => g.name !== '临时邮箱'); + if (firstNormalGroup) { + selectGroup(firstNormalGroup.id); + } } } } catch (error) { @@ -87,6 +93,21 @@ searchInput.value = ''; } + // 重置右侧邮件列 UI(清除上一个分组的残留状态) + currentAccount = null; + const accountBar = document.getElementById('currentAccountBar'); + if (accountBar) accountBar.style.display = 'none'; + const emailListEl = document.getElementById('emailList'); + if (emailListEl) emailListEl.innerHTML = '
📬

请从左侧选择一个邮箱账号

'; + const detailSection = document.getElementById('emailDetailSection'); + if (detailSection) detailSection.style.display = 'none'; + const folderTabs = document.getElementById('folderTabs'); + if (folderTabs) folderTabs.style.display = 'none'; + const emailCount = document.getElementById('emailCount'); + if (emailCount) emailCount.textContent = ''; + const methodTag = document.getElementById('methodTag'); + if (methodTag) methodTag.style.display = 'none'; + // 检查是否是临时邮箱分组 const group = groups.find(g => g.id === groupId); isTempEmailGroup = group && group.name === '临时邮箱'; diff --git a/static/js/features/temp_emails.js b/static/js/features/temp_emails.js index 3e36f4b..1eb5d34 100644 --- a/static/js/features/temp_emails.js +++ b/static/js/features/temp_emails.js @@ -1,5 +1,13 @@ // ==================== 临时邮箱相关 ==================== + // 复制临时邮箱页面顶栏当前邮箱地址 + function copyTempEmailCurrent() { + const el = document.getElementById('tempEmailCurrentName'); + if (el && el.textContent && el.textContent !== '选择一个临时邮箱') { + copyEmail(el.textContent.trim()); + } + } + // 加载临时邮箱列表 async function loadTempEmails(forceRefresh = false) { const container = document.getElementById('accountList'); diff --git a/static/js/main.js b/static/js/main.js index 38891e4..a78353d 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -66,7 +66,13 @@ closeSidebar(); // Load page data if (page === 'dashboard') loadDashboard(); - if (page === 'mailbox' && groups.length === 0) loadGroups(); + if (page === 'mailbox') { + if (groups.length === 0) { + loadGroups(); + } else if (currentGroupId) { + loadAccountsByGroup(currentGroupId); + } + } if (page === 'temp-emails' && typeof loadTempEmails === 'function') loadTempEmails(true); if (page === 'settings') loadSettings(); if (page === 'refresh-log') loadRefreshLogPage(); @@ -429,7 +435,7 @@ // 禁用按钮 const refreshBtn = document.querySelector('.refresh-btn'); - const folderTabs = document.querySelectorAll('.folder-tab'); + const folderTabs = document.querySelectorAll('.email-tab'); if (refreshBtn) { refreshBtn.disabled = true; } @@ -494,7 +500,7 @@ currentFolder = folder; // 更新按钮状态 - document.querySelectorAll('.folder-tab').forEach(tab => { + document.querySelectorAll('.email-tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.folder === folder); }); diff --git a/templates/index.html b/templates/index.html index d765d58..bf037e6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -275,7 +275,7 @@
- 选择一个临时邮箱 + 选择一个临时邮箱
diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 2af83b9..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# 使 tests 成为可导入的包,便于复用测试夹具。 diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 531661228112296fd56f306ce9fe31bdf8939d51..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 151 zcmey&%ge<81XIq=%X9?Nk3k$5V1zP0vj7=W8PXXv8U0o=6fpsLpFvW$%v`NvGD?dx z67#bY^U`Bn5=#?=dlv`~Ypu$ZLUGfspjI3DFj?3^1q3s*#5Qa)8I}Py?kjg@TS1dz z6@mI#tgm88=}SYKwqRJiRB3R7uXhjnSF$9jOedb-Yl4Gi+> zIeXU43M@gqo~NrQZ1NdOkk7>Bt;n+2b}EDM2A2&XY~hXT5VrCr*lit@(>zbajZR9X z_lZhpcR?hk1S63HY)A|QU5X?tvI~U9D3;m-2fIy#v$?I+pLI{55k5V9ONSvA+>2@< z-1OpfS!@OJx^f_R{WG-aw3gu+T1KMP0-4UN7IK#K%HI1K_NH_cbBj4^$EE9+r84kM z0Ac~e`Cl#GOpZ~CS{_x3%USJ*P*5{-_H1F)jQ*=;T>jH`T+!1!-ojhqXRFdP;HeZj z?Xf>V%Q}1Oa)}lnOa63XZtQ$X>e8{~g`c&FxHdMS^SB^V zj)F|P(>r(NNb2;M_T^D6{>gk_d|{%WXKOgtE7Y^^G8K%+?R8hXc~;ox_U>j<-w!NI z41jqGtj>+jjUUx+UeSg|=lZ_Y2FJ9IMi(Yd&L6p>^`6R{B{@7YfALyIqz#_d;^V2C zcT%G#z=Jk^obgmM$&-EB;F;MQ*OC{nColBP-nzcx;_4U4k+aF+@3i4@rcG4D3Tt;I zd5e5OU>Kw ztrco`u2x_h-PH}OM_u!X1HNnO1rJ;6Zf@{8ZK^IJt0pP@UI0hJs$N#GY8E`z>0|@h z1;S9IEf$ny)e;CrBUlLrT2)5|6*3}K4@Fgt{z$kz(4iV*QAm|UT6o!#%mYTPV&Cv} z#^g>hazG4s_&ktKmp{@b`5y0oAIXpG6PEph-_6l3wP=|nmj>2ySiAxJ!h}Kc2y8uQ zF$Xamb*#Ft?nqt#;RL;Y`lZSYHJMwSpqc4S6BIXrNKfiIhxOCBk3>*GLw86M&MfUujy-RRJv}%Tt*_@tX9|A&V7|dQ&M}-lC zHbQTepzXv2R+wt0xJUCQ!y`Md&vrHRBE^|jBv~x$fk7Sgi;v9`6A~dZgNlLQj^5T6 zqjt(^i0uHoJcKfP!_7o;V zmsGq%QiQhF?sXovf%URXE!VV{2}U{sVL?FRj~CSjV@cH z4F<=3N5P4A`gPN0Yuq|uoh+#M)m%vk%YfyS?e7P+!X-q3mf60j2l$UnW@`VYE$FIs zr~WpzQ}49nHNe8;9}gG8rkcr(+5pKIf~xsm;772CyF39b`IQLnQZ0g) z-Mdfl?BP^8EQTZu!yTnxaQeL8AiR+<($_M-m1&%v*abY&J#sZ{a4w3PL3uOCGJ~u$ zDEBwC_MyFS%3glgUOrjjN!ZyQ%Y9q!pyguDnVfsJ;?JE4+bcchzl_gO`Ae@^C`UTR EzmWqw!2kdN diff --git a/tests/__pycache__/test_api_extract_verification.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_api_extract_verification.cpython-313-pytest-8.4.2.pyc deleted file mode 100644 index 1a579245d43978a41afcbfdcb8cbd80bf232924b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5288 zcmcIoYj6|S6}~I2thJCMherc$lPr%=oQ#Yyah;|vkAO)?CJyzAPTOc^XOY)ti>y`e z?uNiG8}NhjauPcxKoi_1ags@7GeepOVEVH^+fq;4hTNLrqjPYcULPfl2&0n zj%IxJKF&S&-tRm2+6_!%pR!B$0>wH5r;71D9-Z|g+1oi z`y#D)SoER~SFu))AN$Q71~cZj8dsa&01iZ%j_=uq7rq^97Rq*sqoUPib-!v^+x-jQXl*m>m;5+yv&SmgEq0wntI;~d(RGOPMg1M?EG?&O_F6T(Ji3Cb zdiECx3Cj@z-Azs{tGo_1=Nnnn?dkT^`?Y(}5XgOUIy>2yx!jjNH*B1Ho87r@FPpnK zkRBRNe|{=EIT{F9-*S4H3n$W_{UI}+O7~9Oy?)v_-Jd@7>D}uW0%6k(*f4(ko@KW% z+<5bJ?yc$c*$?tcxsi9Wlat2#Co;nWcdrknFMX0eIc*G0ebd_q<;*+Rj2oj+&K*l- zQdjPLdB*tcym9HHyVp;(fvY$lNIzEvlHvRyt2qW_ zfU5?%j0=Daa?3#0a6ym{aWx=A+(WpwJ5*n**F{7{ZF^nCLQMU+h@~zmCa98}0PJ%s zVRCIfhyr3R#AL2qi`h#i>nCO65A!sLQPfS(;3v@M~?JEf?ssc~68)Yc+#gsf$dC}Ss|C(b*4apOdGi~h|7aSfF2TMI$i zgqxhTQM4a*ILUxE@*{z1;W&4D>uK#UH0O_0X5^zg)2GQrk$N|CcB;&b?cf&yK~Y4kMzAK9 zK==fq+?8xz(PA-CQS==Z@qGXbPry2r6(GPBo&;Ao7dja_Pygky)*0rfOGm}g@>De0 zz}TV4g7VJI@pRv$9S$><7_g#QA(T1ClaP75-x&Bj5YfmkOV}tkX*(tdxrB`$R%Rn1 zt9+NNB|7!z7R^V&Mk_49W@Md%k+vCT&(bmS%kqqDU0}qz5VEP8xv^9wE^?Q8vmf?Z z+7|u*I62d00C(DSf%?hHP-_W6QxD2mIx2SRZHq?RY33CN;42+~Z=YdyEFIv9@_;{` z2fXOktps=>{8-LkG}7tz#X*QI(siLAT$uuq+)X_3SMha8QS}!V%|el-R~#V{Ys!+sZbvnTa>S@rxlhZyJY57s%@?L!K0I zmyCOaL`>9oFB)!#l^zGw8^1HCMf2%yeRUV}HV^S$Wi^3S(L*v;_j*$ht?RZ4U# zY~cV{_wRj1BHCjxsWbGH&g8`x2Nud$;Gkv?RUkxjFD2TOO;%(W(!zr$xcBPOX2>>gTU=F_`C!0 zZQmK1U%`^+^DoQ>2N}C(n6{;Je-|=-Tp%O;={V5v zz+{xF0~uSwTYwDcszgTVNEb3Txsefrr=;p+V%H*=C^qsppaDNc1WB;Wysf?dVV&Xm zgwP}MydL2B9=TJClX8&ff1?TUd{2nyyCkfr5D&$K4BdX7@04RakCzcsBrn7ynZ_iq z#C1fFf7xJ`2$EpqjYQNF5eA{J;(0{{CyVids$%JY21tN4GnX`1N3uvv@;*}#+sZBw z1L$9Lc#f{})y&m;Km=F$w#_v>?0cS?+Zgceq~=!FLc@j?q;WkY#m-Q37)TCg}0gw2Q(=TSB#fSi zN<>j7(DBf16u6D5Z=+?iRfwVonLcJP)E642hMUG(-e?)y{>JvpO%pBSEfcNdty7*a z{8#*6gsz1C#N2A!`{$=`t>1TR#n1kZI__2N_fmVP%d7vfw&|~Ho9-dAI-9IQba3eC YE$^Ct(!s%(kG?UDJva!>tnaQ{3?EdFJ z=X~FP{&UV`AmBsr>-*`L6j+GRbyCUv%r8)xY@iV8M;yY4qd3Gk#@y__cUgO{=?6X@ov8f~7@!h@gulFX0`+Yv^S;#JNcvt-F7m2}GykqF*rG3V} zuK3;EVw17a#O0XP zy*YmHnYjoF7jPEG0Q$IDfU`Ltpr4x!7~uSXcX0v0Aa@roZ3&i_>Sc|hqSn2u zVj-fwEMloyiU_JCM}hm)eCS+T43K5aL72q3i;>Kb$@oc`#KR;F(2rWEa+e0tfGlc^ zJ!JMqPq%TRr~VW=Md`F6wl*uI6|28c_f&Yhrfe7FH-%_Rc!Qt{p@`fhhGk7{mE||< z+61XJtiq@j6M3Os;zer${M)t}RJ6aRdlXUK+OD(CldLc;?uD)uFF*$x_oG>XNhqTK zoDx}}=_&Odh+pV}u&Kwy3uXYU`CyU-bVd~gyg_~^sxK}wl#J0sq_qd21O3JLG07Qs zjD@gj(z^@_{b(y9X({(d64CAD+u|;#wRgZKy@V2dCvS}IWj8g3Dl1pQG9gRtKh{X5 z<&hYyHE3c5g&E!?>dnQMywYbJIhGBV+Y78wBYD6W?J{D=5(h>K^jHsZ;1d)@#A+kf z#5@WgCXzdf%_~|YA}Wf$wkW?-@bEBQ3;Ptjqm&71K*?u&a<^_6`L6P2m(Ksa&xD#xpbhsyg23U zB5sy(n-GnN`i5!KZS&G*qk8H8Mzvg+>c@fVjm(*~{}ya)jscY~qUC3o*Go zV{)&=Wdt4|z!D%AYP^&{Ie`#>emBo6Dg;@C7gQBX+caPTyqR}Nb9CeuiOIds1mas+ z573SNq(c*Qi6<~o>H_fJ?O8ohF~{>9HL=v^d6t@3Pzo=W%p-5-de%}C%N`=Qj=I;g zk@}~Kpf!8qnoJ!|GEtr{58_qk3oRX(I8w~$#KZ!bZ# bfA@~7?)(0r{XLs^Zti+<+>Pimlc;|I;mRn; diff --git a/tests/__pycache__/test_core_features.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_core_features.cpython-313-pytest-8.4.2.pyc deleted file mode 100644 index 4d6b452e1fb9513c2f1299fe965c5f0e39557ff0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16104 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 Y8!l++KKjnh<=WOvZR;N>a!FM1-?At93IG5A diff --git a/tests/__pycache__/test_core_features.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_core_features.cpython-313-pytest-9.0.2.pyc deleted file mode 100644 index 46aa4ff689f3fbd2611079915497ff88fd923ee7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16078 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 diff --git a/tests/__pycache__/test_core_features.cpython-313.pyc b/tests/__pycache__/test_core_features.cpython-313.pyc deleted file mode 100644 index b906afa22ed5c42ec65a32c9a21b7c8fa57f0d8f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16442 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 diff --git a/tests/__pycache__/test_distributed_lock.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_distributed_lock.cpython-313-pytest-8.4.2.pyc deleted file mode 100644 index b63e05f5fafa982345ab3fc1834f161ea71291ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3959 zcmeHK&2Jk;6rc6_JKH)9PGdI>b()r9NCHYo8%Wv$O-h@fRI+QnYH8TmlXR8Mt~;}) zZOWk%2qlQBaHuFysR)n|9}*n<2O!j=typq2AW;c8^i~K6#EE%puN^lIP*reZWWRZD z=FQBT*_q#OW_8o1AQI`J!7G~NN9cPnoaCu0tGZAwp(IL5lZZ-o?wXL9D~2ZA)Dx@J zc&XQ}@ll_h`>9{#uF1eekOpHYf_9>$ybUGYX zIg?uWG?~U~E{EsMY+fgsdOL(%Rb%yGpom}ldf%OsOmsH5P%5NEu=5$ zq5fHK+{eRX8ndW2owrE#X)`l-oG^>s6pMi}Qne*yFbu@Fx>JZt=tQl~={|+@TG=d$ zOH*+dg$CSP30llX&ghJRQ;a9aA4{Livy-Yhry8^AG1XH0GG>;f#|>sap{32drJLs5 z_`Is=={b#>18Po7TM(Nqakd^c)H6}Yv}u)CLaD1VW)Jcde-bnJDk#bp7JN6b<5F` zwfIwVLl2I_nmp0D#;B^Wjt)E+feltM&@hkno&1wE`fP)~gAIK*`$8>iJfuX|`r)0^ z5JR)58>RlnX9n$;cc2+5?wjI4IP>-i$K0ig|1`ciDhBvO;w_e?V@5Kjk!9R(o;C=@ zS~eb{aEN#$Ly2k;%ueZ=$b^?cGL~i<+?~&BS?&S(2$xTiGd!YZPUSU9)}aay3M?Vi zI!^P1D;51Hn=%c;TPoIJRcC~^I#ovvW~oMoaCcVASdP>9sl2Lld6rl_LWxdP)?_G8 zMmHIWE8NY9KEvG*F%JN?VMCoK+*g^Q>L}CPWzKQ8X3UseHs=Po2h;)Xua1y~(!omU zpbd4gHHiZ8@wmOZ&g@kqi#H7KKvO{d$L-j*&tKSvs=#(*6GK&%-69 zzh0ZV`usc3zrV2DHSpzAC1rG7Fv}gO#UsMq4&q2j+5NY}+Mb6>O3WU_4wsaaT{lD@ z6H5~pp8kDttfKs3f8HzOdk^=ZuY1}K56KPOT_dp%1c3cdV#dEpVkb6>oo=T@tE1I( zaSN=`flVXzs+zVTorz0Zl}v%onvv;$L1%sk>>KDzYDQh_V802y)qQHPADH58b=W6o zasrwKGQo0EAn}`UW1v9Rvx9b8JxJSx#kb@rc}X+#4AVH!@IbAI`<$|%Xk6iEO81JMxj%1c7L?d0fo}no_2cRr zQ@k%L4>tdCQGQ6=Q7W@&oEi6zR^D?5=%nZqP9lSIa7&gX={tm8MX$7cgF@e+mLJjJ WFW!DBdU?3q*<0-F{S^trp68!bcR8#8 diff --git a/tests/__pycache__/test_distributed_lock.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_distributed_lock.cpython-313-pytest-9.0.2.pyc deleted file mode 100644 index a23f48d55c4fcb2beb4cc3d3a786683448edeb75..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3943 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 Q&eceZy8IP|N2CG%55zZ?9smFU diff --git a/tests/__pycache__/test_error_and_trace.cpython-313.pyc b/tests/__pycache__/test_error_and_trace.cpython-313.pyc deleted file mode 100644 index 1c047902d9f4045d8e626e1517a21432e5602c05..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29367 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 diff --git a/tests/__pycache__/test_frontend_manual.cpython-313.pyc b/tests/__pycache__/test_frontend_manual.cpython-313.pyc deleted file mode 100644 index a49fe1ccd0b43397f9dda171cf38e8bdce816795..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15263 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 JB`eu_{ujC@WfA}Y diff --git a/tests/__pycache__/test_gptmail_error_handling.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_gptmail_error_handling.cpython-313-pytest-8.4.2.pyc deleted file mode 100644 index 0e82aa86e38b3e4f625e624df7ac4da7ea063938..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9853 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 EU%sC|4FCWD diff --git a/tests/__pycache__/test_gptmail_error_handling.cpython-313.pyc b/tests/__pycache__/test_gptmail_error_handling.cpython-313.pyc deleted file mode 100644 index 7731ef4b74ec5308c54e823149c410414631e2a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9782 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 K9f4P(s{aGG?#8nK diff --git a/tests/__pycache__/test_masking_audit_and_import.cpython-313.pyc b/tests/__pycache__/test_masking_audit_and_import.cpython-313.pyc deleted file mode 100644 index 4ad40853ae0b04db905cb55a3eed9ecbfc791d5d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15641 zcmd5@Yj7LabzWfcSiA{DTgaqvZZtb0ZR%N3DCQM zEz#qNGt*|EIvI&cG=!Q?M7Noio~8*qX{VaWOvnD=#A)goU_cefCe3i#y!?-rlF0RR zrak8_cJa`Xl$~UzcgTx<+`aeQv-jTfo$sD~;BZ(eNMC#J8}aQsDC)oPL`lZ-!Gq`F z;1Q5PwcQ_!MXqa?{TvGFBM7$DDOdd_g;;EyF z*f5d&EH9?S_Zes`sw=mMKPVn{{lR(2Zc!K1dx}_LYD&EprpA@-Kxrk78wxfXXct@75P+n=IN=3C`#Mb)<;jJm<5Co zTOfOl`nj*G;OoxNU$T}wzWJ`XuKAw1o(%nE^8>hDt3DH4AnJP!4sKDPAo#6M=YV7+{g;9pX$T98TOoUmT5-5)WGLt&igvs7tJAUs*=hT&aDM3Nd4| zrd~4dC1wVO*~>{GCfRU!;@RH5{x?D2FNn#+`|m=Pl37egqr51d4H+fFOj1n23lc?M zNDaN1j!a25F%?OrMR;K`UScNs6o-#WG#tSTZNwT((Wy9}NJ$2P7iUCNN-!9dU!zH0 zfTZ?y-lJT^3y+DV|9`mH&CNc_qDlH^xRu}`^ATRxAaPUc{ zNJ6&-hl)B$pqg@Gb;`>bU+1;LAkr%nCjzyE^{gH%JELQ1QnSWuSEgr>+6og7T3@%! zM0o;+3}fj-P%}Gpdg$r1!FViqeEiI4a8gL7XT;#SlSAV}!9--54;~5bc>jCZUw`|X zzsP*?SKs^79l_JXqr+!|dnJ85_CAickWsL~kK~UEd?dwl;)_#p_<{MH;G?NH3=K1X zm5-)VFj^*fFj2__FBzj#Ns(8^ivbHuw2*wccCfg)7(Wq7Po+2_7Z;C7-Rq5;`qF-o zOFRb|4k0fNp`xol@9HnO_GJvKdXptk@-)qN&vlm?Mjp_5uk%;Vrt6nqe__=~IU0Uv zqT09K?7x}%%ArMf&fc|RZ@6*l`l&CU{xEY)KKMw)j{jx%(PrxHX3x=Xx@It`D~JSI z`ecI%RtAls6jp~)5MQ^(aHH=;R^}kJhe{ZrRoA0Zv8Hf!z1n5Wb!BiBs7oyK7-cNg zWngu+j?FrHBQx<{Fib65CllBc!+ z2Tw}gx~!UB>99=#v7DC-Th zY8gjZU6)u(m_aQo^gVS;uiw)+JR!+Zy%SG^MgB^04-* z7PQMcl(HIYs(p=C?V7DRm72m#RjbSNsSF;A|IIhWciM6whhja-C^br^Ox^(_X&706FFP!c=|oBl z?&Q(tbq5tQ*d3G&7(l!3pukUn!*H2PC9m*_ZW)V~Z%jvoE8Ria^9ViC)U!uVhlkkU z&ZoMA^?#2RmcT!W05E-tpLG(utq9j!ZMeWxbZwOW(H8-a;W zR4r;({9nN1uTeh*e9`=s=C5upy4&;ac7PX~HfNvBo?JMc^X<)8OTOmp{_M`2w-XRS zb8B`Ydp_&TI7&_A^35|D%Zi3a;Z;3~RXy!)T(wfZwo>EfQe$hWvAxvjFE)1N8@mdP z-KEARxW4LQ+@1$Er`MTzdUXrc+=>os=2QthjgjjkUm7jD9A7%UvMv}x_ZY#@*y7+~ zcdq$B!F8|(@>Dy=kAsQ8C)zo;Gkd7u*;e%ITJ&VhDg?@Iy3=yIW#Mcg(6xAKnK@8@ z<6I%IdvSJ|8LYqYd?B!BsY3=&Wv5-PiYo;6EeRS={7E4¨+Ga9vfoZ3hd1Lw5(3 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 L@3$155i$P{fw1iL diff --git a/tests/__pycache__/test_module_boundaries.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_module_boundaries.cpython-313-pytest-8.4.2.pyc deleted file mode 100644 index 7c7c602af18d53a7d540399dc0ae8950a20b9a2a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4812 zcmeHLO>h&*74DH{Bw3a%u*XQ);Aj^LEdMAAwl^49vsthUL*WFCMPN~7YU~-C3G$56 zGXhL z7^MvQ+k$>#SKov77=^ZtrhytD%k~Cd=ObjvT zG}vc&u%u_OGD6uKk%ml@Gc7&}T@XRmna(b2ab(BDH=bkHUxM|MSLml-xtOV+| z_u$+;E`>T;<;*x|R9s7TGJjJ?!fIhB%F$7e_TE59aUZR5WD@Qrt7{!@rR}zDZ?Rdz z8we%b@l_QGrw6*5K{uUn#p@kjrA^SwV!%FI1RaPVRE?mGBea5bYbx5D-pkgBVC@0T zv@q<*R9fc@rqnr*THaE3e3`Y*4T6Qguq;iuHlip!c$&%RqaE7-xjso5QAJr9BmGf1 zB`LzV1Xywlnr<*@0c(;`g(X4cb?KCDxHTOEk#vJTDWr}Yep!&1BSaOe`3mss#lEAi$f@LU8LL}`w4Bt51DKSz^z*}( zYsiuoOHUXfend^Bq!FE$P0MX5&1hT3kzy+dlT&CP138241?w(MygxC;UT?nAJR6#7 z&aLhF^X|{+KMmxAgIW6Sy087LFYCR>h9>v^Wbf3_<-zQpyTP?n8$WU1^xyCogKha> zTWS4k*WbPR?niwkwrO(z#r@L^nVVZ%r0{7hjmoFs63U@~I1B_- zD-_H_=Mr&s8|W}Be-*a`hqQwsfvy8JaHs*xp`iCR^q9ka+YcioHh~nqT<}OR9Mu8( z9Stu4egmvR@p51(n*kv^LDfR2PS87D(9#LUY?)54qX$6$j_scNt#%AWJ%a`n%QS$V z2QxSBUz>dN>FtM?K6vzpYmaW8eKPlPCImB)`@fmH|K2a}{r=p;U;XI8uYZ2;(`%1@ zdpVLJ;p53%_WlRAAHVnE{qr9_oI4ZAkSlmHcRosEh$G1GXy5^oX!yY=u&$kyA?(tl zfXb-HpfxEqYN8&$Mr6Gf>NHF|*YH?Bibevm3Hk_LPxxzj@B|6#eC6k}*BKql!gM8Z za%gQF$Qe|sTT`ry>%F^X-PCt3tJ(g~8{0qflNiZEC~n@C-@NU2 zYyP<5_PXNc{=(+|5*wZzxHyoDbkB;jhjKgOIqpb4p3Egvxp6I@I*~guk!wCZ&;DSM zK}{V`-6*sPM_|V?%%ES%I5B`1WSla{j@nVX0)^$^;$)CGxu};ujv^sLp%RdfIsnd` zn^46C5GrS&$`XjA1oAI^j{XXaIQoU$e0_tD$#ogkcEICLO2d%WVI*l$`gz?Fb}S{02_qBF*`+)H z(g}G)(oTn$aG9}^ESd^}H* z2hST`ENN=$1jKYm9+IMKhTlTu5Y9l3kWR@ElrdZl#Qw3AI*dt%#H~OeC2S^aNH=Mf z$EvCue(PwwC}X3l>;UHWmF!^o$yO5WVHLRwUQ~HSEr)gI)Vz66Ud6n1H%9+*4)o>q ztOKjHvp~+EyKMMs_@{$Kwl&YT7TES8+m&a#W)J4sZ)B@V4b9W*^9`}taK51@>o55N z7lP-5Q~qK&nh!?{;kG-z_63@0@Mo)@1yDoN)b{JUuI!q5v(U74p6$%;DFy21@B9kw|2MSz90Wz(csD`j9ztFT(*bAczlU|V3G0fi z6|`4|v>mkvRs{5yRYe?7gE!TG4BVl}^jrDR8?)QX3EVr2P3`%n_L)P4rmlIm`_;hwrU~X-@P|T>wI+>sTH(^C*uG5P=9Gq#DH}Fqwo@U)m8M=TM1Rz2K^5Let^`f@VgW?Ywnrk#ynB z;;A#|78)7mhtw>)fJpmUckbX&?$D9k!6Sd!^~_{Ft*vE-sHYJ^zlhWTwRR;NMgxX# zI3uTYcxoDfx6>1Rd9;>KPY@KvtQtdBl^B*zVp)e*YnerA5{u`D#U|(kj02$A@MWN? zAskY!=*Lp>uwBm#0dKbefHF_~;w>1yo#W;swikaFl#q%{Bd?YPH$_o@N9Zj2vHx=v c{2K~C^X#UmA!@3n*w~hDZ2JP4L-S<+0#kla1ONa4 diff --git a/tests/__pycache__/test_module_boundaries.cpython-313.pyc b/tests/__pycache__/test_module_boundaries.cpython-313.pyc deleted file mode 100644 index 5644f80ebf50559a5aa7a7f767a25d859b1246b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4681 zcmeHLU2GHC6~5z{u^q>WA(_M`hOBpk2+ohYAqgeG!fHuU0!`X_Vu}_^qe(moQ=A$1 z&KL;l1C@4>sdmNE7Gkzb3GKFohpOpArKLjKO6|k;p;9cP*d6Fo`F$cKY#(^)xicO+ zprmE1_HFOOIp^Mc?z!h)f9IS#UaqO}AZS1S%TML+RU`Cw(y<#`5|$5uZ~^g%rw$@) zaSBt=cN}yapfMdn0rVQ;=_bT8aVqMz4ODn9ZONFOq{8eY(isU;2J=l(KRRAfL&#a` zEGIy?fZ~YkgB?7D={UnX;w(?copBd&t%u>6CiESqx3mPi&3QIn6?gH@9!E*XMRc%- zcQ+wi%~t{S@*bcyd^N6}^oDDU`e8MZPD*d7X(b`z=}}43wMWDegGqsZh7(JA8Y|;e zX(PhOG&$4qeHgufj*v04MIE+O;jf_>irTkX8HW4h9evahmpv3iy`?>Py3e6dN2=@@ z`;3ZX#ZKlgXKPp-cA{(>^=SW9gcSNnjja=-SFEnJM=QLy@gqG3y5 zyBTaVF-NrC9;@&e6XhyBTeAHMq2Ulh)d+YLp*6f~v{zO5i{2%`+hNW0Fi_F5CHoAf z))~-R-BUWc%Gwp4{@n63=GctF%*ZJ=txt6B0OYzQWn4|j$|R8|8Unve`uOv<9B5u}AkGx`U1#*e18qoR6DR3_v7 zqAqrV+frOj>q%8THZUd1$+%7cZ&JasCyZI4AWK>#HEsBXaW$Eg#&tn9JuPWzM*Av( z6InxH5JHdwB$tf{z6?OR~MPn^L0HL*PX_V z*>7J|GlRFAIzDg~>o?@D`hRBZanJTrHE zq4(>b9s2Wug+mhylT*2g)Iv&I2;ga3m>BisRGIxRql7rQ`Crq@4<^9K}*f)nr}8kQq41X+=FL4eM6M+L^$J($FR;T8l^E(F1FVM$Yy$03VDxsViHGdvb1M{yde zf^<@bw2a|uAlpwS)iF$JB5nf$MWB@oLxD-D0#;Ss@K{F^5;8Wb${}EGU#SOHq--Pk z9#)a75E81OsO7|NpIR^vDyUen?#38gErhk-JhR|!%X|4J zF4WNTWx)M!0}hkwgd;!<@5(?4GX^`66t!c|N1X*g+(j0_I}_n;gme?qLkLMPC7|Qo zAX{h$#L5`&AqwV;qWspeu)WK|+w&ns`^MSTo?)K-<-gjlsr9E?7^1-e}ZrjVDz57eF z|KHH=wh&u&i_WnYeU&v2(;${?fF3GEq7#zVa&wsA&Gl;A=r@%cFY~h2YVK|-j@UO zb`#9k;V%Rqsi)*y3F@gwus>e}!z&2iO^eCWjE~e@tm#QvkrWkFz3}YRwKSBtaGoLg$DVjGM8;1W%E*Y4NHz;Qu@{)(PQ&wq;Dx*^^rK06tn~00g|U4i{PL!2 z&%0~5cTbt$mwPb0wh(zbX`6v8(G*49MxNWq_cs)H>iil-jZ)dIg{JmgQ~T%0?3yn8 E3)9LUGynhq diff --git a/tests/__pycache__/test_performance.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_performance.cpython-313-pytest-8.4.2.pyc deleted file mode 100644 index 3a09e4e21c7bed779bb8b98266222634953be041..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4731 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 diff --git a/tests/__pycache__/test_smoke_contract.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_smoke_contract.cpython-313-pytest-8.4.2.pyc deleted file mode 100644 index cb53e1a4133202c90bcb825f743da93a12d41b10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8586 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 diff --git a/tests/__pycache__/test_smoke_contract.cpython-313.pyc b/tests/__pycache__/test_smoke_contract.cpython-313.pyc deleted file mode 100644 index cf7927eb582d78af407191d736b16afdf04cbe23..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8797 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 EzrXY$Pyhe` diff --git a/tests/__pycache__/test_ui_redesign_bugs.cpython-313.pyc b/tests/__pycache__/test_ui_redesign_bugs.cpython-313.pyc deleted file mode 100644 index e31cdec4b7e0631f176ae409e4fc03fd9f7aa5ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14320 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 diff --git a/tests/__pycache__/test_verification_extractor.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_verification_extractor.cpython-313-pytest-8.4.2.pyc deleted file mode 100644 index a1e11188c269818663a5c65b9bfbf955db28d640..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18973 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 diff --git a/tests/__pycache__/test_verification_extractor.cpython-313.pyc b/tests/__pycache__/test_verification_extractor.cpython-313.pyc deleted file mode 100644 index e0addcd86d7d7f3141aa3298968b352891af5f69..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18836 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 E56!%tSpWb4 diff --git a/tests/_import_app.py b/tests/_import_app.py deleted file mode 100644 index 65e1a17..0000000 --- a/tests/_import_app.py +++ /dev/null @@ -1,46 +0,0 @@ -import importlib -import os -import sys -import tempfile -from pathlib import Path - -_TEMP_DIR = tempfile.TemporaryDirectory(prefix="outlookEmail-tests-") -_DB_PATH = Path(_TEMP_DIR.name) / "test.db" - - -def import_web_app_module(): - """ - 以"可测试"的方式导入 web_outlook_app: - - 注入必要环境变量(SECRET_KEY / DATABASE_PATH 等) - - 禁用调度器自启动,避免测试期间启动后台线程 - - 将 DB 指向临时文件,避免污染本地 data/ - """ - os.environ["SECRET_KEY"] = "test-secret-key-32bytes-minimum-0000000000000000" - os.environ["LOGIN_PASSWORD"] = "testpass123" # >= 8 - os.environ["SCHEDULER_AUTOSTART"] = "false" - os.environ["DATABASE_PATH"] = str(_DB_PATH) - - if "web_outlook_app" in sys.modules: - return sys.modules["web_outlook_app"] - - module = importlib.import_module("web_outlook_app") - - # 测试配置:禁用 CSRF(避免额外 token 依赖),开启 TESTING - module.app.config.update( - TESTING=True, - WTF_CSRF_ENABLED=False, - WTF_CSRF_CHECK_DEFAULT=False, - ) - return module - - -def clear_login_attempts(): - """清理登录限制记录,避免测试间互相影响""" - from outlook_web.db import get_db - - try: - db = get_db() - db.execute("DELETE FROM login_attempts") - db.commit() - except Exception: - pass diff --git a/tests/layout-system/README.md b/tests/layout-system/README.md deleted file mode 100644 index b4714cc..0000000 --- a/tests/layout-system/README.md +++ /dev/null @@ -1,289 +0,0 @@ -# 布局系统测试运行指南 - -## 测试概述 - -本目录包含可调整布局系统的完整测试套件,包括单元测试、集成测试、性能测试和可访问性测试。 - -## 目录结构 - -``` -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 中提出。 diff --git a/tests/layout-system/accessibility/layout-accessibility.test.js b/tests/layout-system/accessibility/layout-accessibility.test.js deleted file mode 100644 index df44c36..0000000 --- a/tests/layout-system/accessibility/layout-accessibility.test.js +++ /dev/null @@ -1,353 +0,0 @@ -/** - * 布局系统可访问性测试 - * - * 测试目标: - * - ARIA 属性完整性 - * - 键盘导航 - * - 焦点管理 - * - 屏幕阅读器兼容性(aria-label, aria-expanded, aria-valuenow) - * - * 参考文档:TD 第九章 - 可访问性测试方案 - * 标准:WCAG 2.1 AA - */ - -const { - createLayoutDOM, - getCSSVariable, - simulateKeyPress, - wait, - hasFocus -} = require('../test-utils'); - -const LayoutManager = require('../../../static/js/layout-manager.js'); - -// ======================== -// 1. Resizer ARIA 属性 -// ======================== -describe('可访问性 - Resizer ARIA 属性', () => { - beforeEach(() => { - createLayoutDOM(); - }); - - test('所有 resizer 应有 role="separator"', () => { - const resizers = document.querySelectorAll('.resizer'); - resizers.forEach(resizer => { - expect(resizer.getAttribute('role')).toBe('separator'); - }); - }); - - test('所有 resizer 应有 aria-orientation="vertical"', () => { - const resizers = document.querySelectorAll('.resizer'); - resizers.forEach(resizer => { - expect(resizer.getAttribute('aria-orientation')).toBe('vertical'); - }); - }); - - test('所有 resizer 应有 aria-label', () => { - const resizers = document.querySelectorAll('.resizer'); - resizers.forEach(resizer => { - const label = resizer.getAttribute('aria-label'); - expect(label).toBeTruthy(); - expect(label.length).toBeGreaterThan(0); - }); - }); - - test('所有 resizer 应有 aria-valuenow/min/max', () => { - const resizers = document.querySelectorAll('.resizer'); - resizers.forEach(resizer => { - expect(resizer.getAttribute('aria-valuenow')).toBeTruthy(); - expect(resizer.getAttribute('aria-valuemin')).toBeTruthy(); - expect(resizer.getAttribute('aria-valuemax')).toBeTruthy(); - - // 数值应为正整数 - const now = parseInt(resizer.getAttribute('aria-valuenow')); - const min = parseInt(resizer.getAttribute('aria-valuemin')); - const max = parseInt(resizer.getAttribute('aria-valuemax')); - - expect(now).toBeGreaterThan(0); - expect(min).toBeGreaterThan(0); - expect(max).toBeGreaterThan(min); - expect(now).toBeGreaterThanOrEqual(min); - expect(now).toBeLessThanOrEqual(max); - }); - }); - - test('所有 resizer 应有 tabindex="0" 以支持键盘聚焦', () => { - const resizers = document.querySelectorAll('.resizer'); - resizers.forEach(resizer => { - expect(resizer.getAttribute('tabindex')).toBe('0'); - }); - }); - - test('所有 resizer 应有 data-resizer 属性标识对应面板', () => { - const expected = ['groups', 'accounts', 'emails']; - const resizers = document.querySelectorAll('.resizer'); - - const actual = Array.from(resizers).map(r => r.getAttribute('data-resizer')); - expected.forEach(panel => { - expect(actual).toContain(panel); - }); - }); -}); - -// ======================== -// 2. 折叠按钮 ARIA 属性 -// ======================== -describe('可访问性 - 折叠按钮 ARIA 属性', () => { - beforeEach(() => { - createLayoutDOM(); - }); - - test('所有折叠按钮应有 aria-expanded 属性', () => { - const buttons = document.querySelectorAll('.collapse-btn'); - buttons.forEach(btn => { - expect(btn.getAttribute('aria-expanded')).toBeTruthy(); - }); - }); - - test('所有折叠按钮应有 aria-label 属性', () => { - const buttons = document.querySelectorAll('.collapse-btn'); - buttons.forEach(btn => { - const label = btn.getAttribute('aria-label'); - expect(label).toBeTruthy(); - expect(label.length).toBeGreaterThan(0); - }); - }); - - test('所有折叠按钮应有 data-panel 属性', () => { - const buttons = document.querySelectorAll('.collapse-btn'); - buttons.forEach(btn => { - expect(btn.getAttribute('data-panel')).toBeTruthy(); - }); - }); - - test('初始状态下所有面板 aria-expanded 应为 "true"', () => { - const buttons = document.querySelectorAll('.collapse-btn'); - buttons.forEach(btn => { - expect(btn.getAttribute('aria-expanded')).toBe('true'); - }); - }); -}); - -// ======================== -// 3. 折叠/展开时 ARIA 更新 -// ======================== -describe('可访问性 - 折叠/展开 ARIA 更新', () => { - let manager; - - beforeEach(() => { - createLayoutDOM(); - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 }); - manager = new LayoutManager(); - manager.init(); - }); - - test('折叠面板后 aria-expanded 应更新为 "false"', () => { - manager.collapsePanel('groups'); - - const btn = document.querySelector('.collapse-btn[data-panel="groups"]'); - expect(btn.getAttribute('aria-expanded')).toBe('false'); - }); - - test('展开面板后 aria-expanded 应恢复为 "true"', () => { - manager.collapsePanel('groups'); - manager.expandPanel('groups'); - - const btn = document.querySelector('.collapse-btn[data-panel="groups"]'); - expect(btn.getAttribute('aria-expanded')).toBe('true'); - }); - - test('togglePanel 应正确切换 aria-expanded', () => { - const btn = document.querySelector('.collapse-btn[data-panel="accounts"]'); - - expect(btn.getAttribute('aria-expanded')).toBe('true'); - - manager.togglePanel('accounts'); - expect(btn.getAttribute('aria-expanded')).toBe('false'); - - manager.togglePanel('accounts'); - expect(btn.getAttribute('aria-expanded')).toBe('true'); - }); -}); - -// ======================== -// 4. 键盘导航 -// ======================== -describe('可访问性 - 键盘导航', () => { - let manager; - - beforeEach(() => { - createLayoutDOM(); - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 }); - manager = new LayoutManager(); - manager.init(); - }); - - test('resizer 应可通过 Tab 键聚焦', () => { - const resizer = document.querySelector('[data-resizer="groups"]'); - resizer.focus(); - expect(document.activeElement).toBe(resizer); - }); - - test('← 键应减小面板宽度(每次 10px)', () => { - const resizer = document.querySelector('[data-resizer="groups"]'); - resizer.focus(); - - const initialWidth = getCSSVariable('--group-panel-width'); - const initialValue = parseInt(initialWidth); - - simulateKeyPress(resizer, 'ArrowLeft', false); - - const newWidth = getCSSVariable('--group-panel-width'); - const newValue = parseInt(newWidth); - - // 宽度应减小(可能受最小宽度限制) - expect(newValue).toBeLessThanOrEqual(initialValue); - }); - - test('→ 键应增大面板宽度(每次 10px)', () => { - const resizer = document.querySelector('[data-resizer="groups"]'); - resizer.focus(); - - const initialWidth = getCSSVariable('--group-panel-width'); - const initialValue = parseInt(initialWidth); - - simulateKeyPress(resizer, 'ArrowRight', false); - - const newWidth = getCSSVariable('--group-panel-width'); - const newValue = parseInt(newWidth); - - // 宽度应增大(可能受最大宽度限制) - expect(newValue).toBeGreaterThanOrEqual(initialValue); - }); - - test('Shift + → 键应快速增大面板宽度(每次 50px)', () => { - const resizer = document.querySelector('[data-resizer="groups"]'); - resizer.focus(); - - // 先用普通 → 键测量普通步进 - const before1 = parseInt(getCSSVariable('--group-panel-width')); - simulateKeyPress(resizer, 'ArrowRight', false); - const after1 = parseInt(getCSSVariable('--group-panel-width')); - const normalDelta = after1 - before1; - - // 再用 Shift + → 键测量快速步进 - const before2 = parseInt(getCSSVariable('--group-panel-width')); - simulateKeyPress(resizer, 'ArrowRight', true); - const after2 = parseInt(getCSSVariable('--group-panel-width')); - const shiftDelta = after2 - before2; - - // Shift 步进应 >= 普通步进(除非已达最大宽度限制) - if (after2 < 400) { - expect(shiftDelta).toBeGreaterThanOrEqual(normalDelta); - } else { - expect(shiftDelta).toBeGreaterThanOrEqual(0); - } - }); - - test('折叠按钮绑定了 click 事件并可触发折叠', () => { - const btn = document.querySelector('.collapse-btn[data-panel="groups"]'); - btn.focus(); - - // LayoutManager 通过 addEventListener('click') 绑定折叠逻辑 - // 在 jsdom 中直接派发 click 事件 - btn.dispatchEvent(new MouseEvent('click', { bubbles: true })); - - const panel = document.querySelector('[data-panel="groups"]'); - expect(panel.classList.contains('collapsed')).toBe(true); - }); -}); - -// ======================== -// 5. 键盘调整后 ARIA 值更新 -// ======================== -describe('可访问性 - 键盘调整 ARIA 值更新', () => { - let manager; - - beforeEach(() => { - createLayoutDOM(); - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 }); - manager = new LayoutManager(); - manager.init(); - }); - - test('键盘调整宽度后 aria-valuenow 应更新', () => { - const resizer = document.querySelector('[data-resizer="groups"]'); - const initialValue = resizer.getAttribute('aria-valuenow'); - - resizer.focus(); - simulateKeyPress(resizer, 'ArrowRight', false); - - const newValue = resizer.getAttribute('aria-valuenow'); - // 值应发生变化(除非已达到最大值) - const initial = parseInt(initialValue); - const updated = parseInt(newValue); - expect(updated).toBeGreaterThanOrEqual(initial); - }); -}); - -// ======================== -// 6. 面板语义结构 -// ======================== -describe('可访问性 - 面板结构', () => { - beforeEach(() => { - createLayoutDOM(); - }); - - test('每个可折叠面板应有 panel-header 和 panel-content', () => { - const panels = document.querySelectorAll('.resizable-panel'); - panels.forEach(panel => { - expect(panel.querySelector('.panel-header')).toBeTruthy(); - expect(panel.querySelector('.panel-content')).toBeTruthy(); - }); - }); - - test('邮件详情面板应存在且不可折叠', () => { - const detail = document.querySelector('.email-detail-panel'); - expect(detail).toBeTruthy(); - - // 不应有折叠按钮 - const collapseBtn = detail.querySelector('.collapse-btn'); - expect(collapseBtn).toBeNull(); - }); - - test('main-container 应包含所有面板', () => { - const container = document.querySelector('.main-container'); - expect(container).toBeTruthy(); - - const panels = container.querySelectorAll('.resizable-panel'); - expect(panels.length).toBe(3); - - const detail = container.querySelector('.email-detail-panel'); - expect(detail).toBeTruthy(); - }); -}); - -// ======================== -// 7. 折叠指示条可访问性 -// ======================== -describe('可访问性 - 折叠指示条', () => { - let manager; - - beforeEach(() => { - createLayoutDOM(); - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 }); - manager = new LayoutManager(); - manager.init(); - }); - - test('折叠后应创建可交互的指示条', () => { - manager.collapsePanel('groups'); - - // 检查是否存在折叠指示条 - const indicator = document.querySelector('.collapsed-indicator'); - if (indicator) { - // 指示条应可聚焦或可点击 - expect( - indicator.getAttribute('tabindex') === '0' || - indicator.getAttribute('role') === 'button' || - indicator.tagName === 'BUTTON' - ).toBe(true); - } - }); -}); diff --git a/tests/layout-system/coverage/clover.xml b/tests/layout-system/coverage/clover.xml deleted file mode 100644 index f0ed2e9..0000000 --- a/tests/layout-system/coverage/clover.xml +++ /dev/null @@ -1,656 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/layout-system/coverage/coverage-final.json b/tests/layout-system/coverage/coverage-final.json deleted file mode 100644 index 3654944..0000000 --- a/tests/layout-system/coverage/coverage-final.json +++ /dev/null @@ -1,3 +0,0 @@ -{"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":207,"column":43}},"70":{"start":{"line":211,"column":8},"end":{"line":217,"column":9}},"71":{"start":{"line":213,"column":35},"end":{"line":213,"column":64}},"72":{"start":{"line":214,"column":10},"end":{"line":216,"column":11}},"73":{"start":{"line":215,"column":12},"end":{"line":215,"column":42}},"74":{"start":{"line":219,"column":8},"end":{"line":224,"column":9}},"75":{"start":{"line":221,"column":10},"end":{"line":221,"column":72}},"76":{"start":{"line":226,"column":6},"end":{"line":226,"column":18}},"77":{"start":{"line":236,"column":20},"end":{"line":236,"column":24}},"78":{"start":{"line":238,"column":6},"end":{"line":259,"column":8}},"79":{"start":{"line":240,"column":10},"end":{"line":240,"column":45}},"80":{"start":{"line":240,"column":23},"end":{"line":240,"column":45}},"81":{"start":{"line":241,"column":10},"end":{"line":244,"column":20}},"82":{"start":{"line":242,"column":12},"end":{"line":242,"column":27}},"83":{"start":{"line":243,"column":12},"end":{"line":243,"column":26}},"84":{"start":{"line":247,"column":10},"end":{"line":250,"column":11}},"85":{"start":{"line":248,"column":12},"end":{"line":248,"column":34}},"86":{"start":{"line":249,"column":12},"end":{"line":249,"column":27}},"87":{"start":{"line":251,"column":10},"end":{"line":251,"column":24}},"88":{"start":{"line":254,"column":10},"end":{"line":257,"column":11}},"89":{"start":{"line":255,"column":12},"end":{"line":255,"column":34}},"90":{"start":{"line":256,"column":12},"end":{"line":256,"column":27}},"91":{"start":{"line":268,"column":21},"end":{"line":268,"column":23}},"92":{"start":{"line":269,"column":6},"end":{"line":291,"column":9}},"93":{"start":{"line":270,"column":22},"end":{"line":270,"column":48}},"94":{"start":{"line":273,"column":25},"end":{"line":273,"column":55}},"95":{"start":{"line":275,"column":10},"end":{"line":277,"column":12}},"96":{"start":{"line":278,"column":28},"end":{"line":278,"column":50}},"97":{"start":{"line":281,"column":32},"end":{"line":281,"column":75}},"98":{"start":{"line":282,"column":22},"end":{"line":282,"column":92}},"99":{"start":{"line":286,"column":10},"end":{"line":288,"column":48}},"100":{"start":{"line":290,"column":8},"end":{"line":290,"column":62}},"101":{"start":{"line":293,"column":6},"end":{"line":298,"column":8}},"102":{"start":{"line":316,"column":6},"end":{"line":353,"column":7}},"103":{"start":{"line":317,"column":26},"end":{"line":321,"column":9}},"104":{"start":{"line":318,"column":10},"end":{"line":318,"column":65}},"105":{"start":{"line":318,"column":53},"end":{"line":318,"column":65}},"106":{"start":{"line":319,"column":23},"end":{"line":319,"column":43}},"107":{"start":{"line":320,"column":10},"end":{"line":320,"column":36}},"108":{"start":{"line":324,"column":8},"end":{"line":345,"column":9}},"109":{"start":{"line":326,"column":12},"end":{"line":326,"column":88}},"110":{"start":{"line":327,"column":35},"end":{"line":327,"column":52}},"111":{"start":{"line":328,"column":10},"end":{"line":328,"column":56}},"112":{"start":{"line":328,"column":32},"end":{"line":328,"column":56}},"113":{"start":{"line":331,"column":12},"end":{"line":333,"column":20}},"114":{"start":{"line":334,"column":35},"end":{"line":334,"column":52}},"115":{"start":{"line":335,"column":10},"end":{"line":335,"column":56}},"116":{"start":{"line":335,"column":32},"end":{"line":335,"column":56}},"117":{"start":{"line":339,"column":12},"end":{"line":341,"column":20}},"118":{"start":{"line":342,"column":25},"end":{"line":342,"column":67}},"119":{"start":{"line":343,"column":35},"end":{"line":343,"column":52}},"120":{"start":{"line":344,"column":10},"end":{"line":344,"column":56}},"121":{"start":{"line":344,"column":32},"end":{"line":344,"column":56}},"122":{"start":{"line":348,"column":25},"end":{"line":348,"column":61}},"123":{"start":{"line":349,"column":35},"end":{"line":349,"column":54}},"124":{"start":{"line":350,"column":8},"end":{"line":350,"column":58}},"125":{"start":{"line":350,"column":32},"end":{"line":350,"column":58}},"126":{"start":{"line":355,"column":6},"end":{"line":355,"column":18}},"127":{"start":{"line":363,"column":6},"end":{"line":363,"column":91}},"128":{"start":{"line":363,"column":78},"end":{"line":363,"column":91}},"129":{"start":{"line":365,"column":21},"end":{"line":365,"column":55}},"130":{"start":{"line":366,"column":20},"end":{"line":366,"column":49}},"131":{"start":{"line":367,"column":6},"end":{"line":367,"column":53}},"132":{"start":{"line":375,"column":6},"end":{"line":375,"column":55}},"133":{"start":{"line":375,"column":31},"end":{"line":375,"column":55}},"134":{"start":{"line":378,"column":6},"end":{"line":378,"column":42}},"135":{"start":{"line":391,"column":6},"end":{"line":391,"column":91}},"136":{"start":{"line":391,"column":78},"end":{"line":391,"column":91}},"137":{"start":{"line":393,"column":21},"end":{"line":393,"column":55}},"138":{"start":{"line":394,"column":20},"end":{"line":394,"column":50}},"139":{"start":{"line":395,"column":6},"end":{"line":395,"column":48}},"140":{"start":{"line":395,"column":35},"end":{"line":395,"column":48}},"141":{"start":{"line":397,"column":6},"end":{"line":403,"column":9}},"142":{"start":{"line":398,"column":8},"end":{"line":398,"column":37}},"143":{"start":{"line":401,"column":8},"end":{"line":401,"column":39}},"144":{"start":{"line":402,"column":8},"end":{"line":402,"column":47}},"145":{"start":{"line":405,"column":6},"end":{"line":405,"column":18}},"146":{"start":{"line":414,"column":8},"end":{"line":416,"column":16}},"147":{"start":{"line":417,"column":6},"end":{"line":420,"column":7}},"148":{"start":{"line":418,"column":8},"end":{"line":418,"column":13}},"149":{"start":{"line":419,"column":8},"end":{"line":419,"column":15}},"150":{"start":{"line":422,"column":6},"end":{"line":422,"column":49}},"151":{"start":{"line":423,"column":6},"end":{"line":439,"column":7}},"152":{"start":{"line":424,"column":8},"end":{"line":424,"column":13}},"153":{"start":{"line":426,"column":23},"end":{"line":432,"column":9}},"154":{"start":{"line":427,"column":10},"end":{"line":431,"column":11}},"155":{"start":{"line":428,"column":12},"end":{"line":428,"column":58}},"156":{"start":{"line":434,"column":8},"end":{"line":438,"column":9}},"157":{"start":{"line":435,"column":10},"end":{"line":435,"column":40}},"158":{"start":{"line":437,"column":10},"end":{"line":437,"column":32}},"159":{"start":{"line":448,"column":8},"end":{"line":450,"column":16}},"160":{"start":{"line":451,"column":6},"end":{"line":454,"column":7}},"161":{"start":{"line":452,"column":8},"end":{"line":452,"column":13}},"162":{"start":{"line":453,"column":8},"end":{"line":453,"column":15}},"163":{"start":{"line":456,"column":6},"end":{"line":456,"column":45}},"164":{"start":{"line":457,"column":6},"end":{"line":468,"column":7}},"165":{"start":{"line":458,"column":8},"end":{"line":458,"column":13}},"166":{"start":{"line":461,"column":8},"end":{"line":467,"column":16}},"167":{"start":{"line":462,"column":10},"end":{"line":466,"column":11}},"168":{"start":{"line":463,"column":12},"end":{"line":463,"column":54}},"169":{"start":{"line":483,"column":6},"end":{"line":485,"column":7}},"170":{"start":{"line":484,"column":8},"end":{"line":484,"column":36}},"171":{"start":{"line":487,"column":21},"end":{"line":487,"column":55}},"172":{"start":{"line":488,"column":6},"end":{"line":494,"column":7}},"173":{"start":{"line":489,"column":8},"end":{"line":491,"column":9}},"174":{"start":{"line":490,"column":10},"end":{"line":490,"column":42}},"175":{"start":{"line":496,"column":30},"end":{"line":496,"column":73}},"176":{"start":{"line":498,"column":6},"end":{"line":517,"column":9}},"177":{"start":{"line":499,"column":8},"end":{"line":512,"column":11}},"178":{"start":{"line":500,"column":24},"end":{"line":500,"column":50}},"179":{"start":{"line":501,"column":10},"end":{"line":501,"column":29}},"180":{"start":{"line":501,"column":22},"end":{"line":501,"column":29}},"181":{"start":{"line":504,"column":10},"end":{"line":504,"column":45}},"182":{"start":{"line":505,"column":10},"end":{"line":505,"column":46}},"183":{"start":{"line":508,"column":10},"end":{"line":508,"column":67}},"184":{"start":{"line":511,"column":10},"end":{"line":511,"column":59}},"185":{"start":{"line":515,"column":8},"end":{"line":515,"column":39}},"186":{"start":{"line":516,"column":8},"end":{"line":516,"column":47}},"187":{"start":{"line":519,"column":6},"end":{"line":519,"column":40}},"188":{"start":{"line":527,"column":21},"end":{"line":527,"column":64}},"189":{"start":{"line":528,"column":6},"end":{"line":528,"column":26}},"190":{"start":{"line":528,"column":19},"end":{"line":528,"column":26}},"191":{"start":{"line":530,"column":6},"end":{"line":555,"column":9}},"192":{"start":{"line":531,"column":22},"end":{"line":531,"column":48}},"193":{"start":{"line":532,"column":27},"end":{"line":532,"column":44}},"194":{"start":{"line":533,"column":8},"end":{"line":533,"column":42}},"195":{"start":{"line":533,"column":35},"end":{"line":533,"column":42}},"196":{"start":{"line":536,"column":28},"end":{"line":536,"column":58}},"197":{"start":{"line":537,"column":8},"end":{"line":542,"column":9}},"198":{"start":{"line":538,"column":22},"end":{"line":538,"column":49}},"199":{"start":{"line":539,"column":22},"end":{"line":539,"column":49}},"200":{"start":{"line":540,"column":26},"end":{"line":540,"column":67}},"201":{"start":{"line":541,"column":10},"end":{"line":541,"column":48}},"202":{"start":{"line":545,"column":8},"end":{"line":545,"column":43}},"203":{"start":{"line":548,"column":8},"end":{"line":554,"column":9}},"204":{"start":{"line":549,"column":10},"end":{"line":549,"column":43}},"205":{"start":{"line":550,"column":10},"end":{"line":550,"column":58}},"206":{"start":{"line":552,"column":10},"end":{"line":552,"column":46}},"207":{"start":{"line":553,"column":10},"end":{"line":553,"column":59}},"208":{"start":{"line":564,"column":20},"end":{"line":564,"column":46}},"209":{"start":{"line":565,"column":6},"end":{"line":565,"column":25}},"210":{"start":{"line":565,"column":18},"end":{"line":565,"column":25}},"211":{"start":{"line":567,"column":18},"end":{"line":567,"column":54}},"212":{"start":{"line":568,"column":6},"end":{"line":568,"column":23}},"213":{"start":{"line":568,"column":16},"end":{"line":568,"column":23}},"214":{"start":{"line":570,"column":6},"end":{"line":576,"column":7}},"215":{"start":{"line":571,"column":8},"end":{"line":571,"column":51}},"216":{"start":{"line":572,"column":8},"end":{"line":572,"column":85}},"217":{"start":{"line":573,"column":8},"end":{"line":573,"column":80}},"218":{"start":{"line":574,"column":8},"end":{"line":574,"column":30}},"219":{"start":{"line":575,"column":8},"end":{"line":575,"column":15}},"220":{"start":{"line":578,"column":6},"end":{"line":578,"column":48}},"221":{"start":{"line":579,"column":6},"end":{"line":579,"column":83}},"222":{"start":{"line":580,"column":6},"end":{"line":580,"column":78}},"223":{"start":{"line":581,"column":6},"end":{"line":581,"column":28}},"224":{"start":{"line":590,"column":6},"end":{"line":599,"column":7}},"225":{"start":{"line":592,"column":10},"end":{"line":592,"column":22}},"226":{"start":{"line":594,"column":10},"end":{"line":594,"column":22}},"227":{"start":{"line":596,"column":10},"end":{"line":596,"column":24}},"228":{"start":{"line":598,"column":10},"end":{"line":598,"column":22}},"229":{"start":{"line":607,"column":18},"end":{"line":607,"column":63}},"230":{"start":{"line":608,"column":24},"end":{"line":608,"column":69}},"231":{"start":{"line":609,"column":6},"end":{"line":609,"column":29}},"232":{"start":{"line":609,"column":22},"end":{"line":609,"column":29}},"233":{"start":{"line":611,"column":6},"end":{"line":611,"column":25}},"234":{"start":{"line":612,"column":6},"end":{"line":612,"column":34}},"235":{"start":{"line":620,"column":6},"end":{"line":620,"column":30}},"236":{"start":{"line":622,"column":6},"end":{"line":658,"column":9}},"237":{"start":{"line":623,"column":22},"end":{"line":623,"column":48}},"238":{"start":{"line":624,"column":8},"end":{"line":624,"column":27}},"239":{"start":{"line":624,"column":20},"end":{"line":624,"column":27}},"240":{"start":{"line":626,"column":24},"end":{"line":628,"column":9}},"241":{"start":{"line":629,"column":8},"end":{"line":650,"column":9}},"242":{"start":{"line":630,"column":10},"end":{"line":630,"column":52}},"243":{"start":{"line":631,"column":10},"end":{"line":631,"column":54}},"244":{"start":{"line":632,"column":10},"end":{"line":632,"column":46}},"245":{"start":{"line":633,"column":10},"end":{"line":633,"column":51}},"246":{"start":{"line":634,"column":10},"end":{"line":634,"column":50}},"247":{"start":{"line":635,"column":10},"end":{"line":638,"column":12}},"248":{"start":{"line":639,"column":10},"end":{"line":642,"column":12}},"249":{"start":{"line":644,"column":23},"end":{"line":644,"column":53}},"250":{"start":{"line":645,"column":10},"end":{"line":645,"column":44}},"251":{"start":{"line":646,"column":10},"end":{"line":646,"column":65}},"252":{"start":{"line":647,"column":10},"end":{"line":647,"column":38}},"253":{"start":{"line":649,"column":10},"end":{"line":649,"column":39}},"254":{"start":{"line":652,"column":8},"end":{"line":652,"column":71}},"255":{"start":{"line":653,"column":8},"end":{"line":653,"column":68}},"256":{"start":{"line":654,"column":8},"end":{"line":654,"column":75}},"257":{"start":{"line":655,"column":8},"end":{"line":655,"column":72}},"258":{"start":{"line":657,"column":8},"end":{"line":657,"column":50}},"259":{"start":{"line":666,"column":24},"end":{"line":666,"column":69}},"260":{"start":{"line":667,"column":24},"end":{"line":667,"column":87}},"261":{"start":{"line":668,"column":6},"end":{"line":668,"column":29}},"262":{"start":{"line":668,"column":22},"end":{"line":668,"column":29}},"263":{"start":{"line":670,"column":6},"end":{"line":670,"column":25}},"264":{"start":{"line":671,"column":6},"end":{"line":671,"column":34}},"265":{"start":{"line":679,"column":6},"end":{"line":679,"column":21}},"266":{"start":{"line":679,"column":14},"end":{"line":679,"column":21}},"267":{"start":{"line":680,"column":18},"end":{"line":680,"column":23}},"268":{"start":{"line":681,"column":6},"end":{"line":681,"column":49}},"269":{"start":{"line":681,"column":42},"end":{"line":681,"column":49}},"270":{"start":{"line":683,"column":6},"end":{"line":683,"column":25}},"271":{"start":{"line":684,"column":6},"end":{"line":687,"column":9}},"272":{"start":{"line":695,"column":29},"end":{"line":695,"column":62}},"273":{"start":{"line":696,"column":19},"end":{"line":696,"column":20}},"274":{"start":{"line":698,"column":6},"end":{"line":709,"column":9}},"275":{"start":{"line":699,"column":22},"end":{"line":699,"column":48}},"276":{"start":{"line":700,"column":26},"end":{"line":700,"column":56}},"277":{"start":{"line":701,"column":8},"end":{"line":701,"column":41}},"278":{"start":{"line":701,"column":34},"end":{"line":701,"column":41}},"279":{"start":{"line":703,"column":8},"end":{"line":708,"column":9}},"280":{"start":{"line":704,"column":10},"end":{"line":704,"column":85}},"281":{"start":{"line":705,"column":10},"end":{"line":705,"column":35}},"282":{"start":{"line":707,"column":10},"end":{"line":707,"column":73}},"283":{"start":{"line":717,"column":18},"end":{"line":719,"column":7}},"284":{"start":{"line":720,"column":21},"end":{"line":720,"column":38}},"285":{"start":{"line":721,"column":6},"end":{"line":721,"column":43}},"286":{"start":{"line":730,"column":6},"end":{"line":739,"column":7}},"287":{"start":{"line":732,"column":10},"end":{"line":732,"column":21}},"288":{"start":{"line":734,"column":10},"end":{"line":734,"column":21}},"289":{"start":{"line":736,"column":10},"end":{"line":736,"column":21}},"290":{"start":{"line":738,"column":10},"end":{"line":738,"column":21}},"291":{"start":{"line":748,"column":6},"end":{"line":757,"column":7}},"292":{"start":{"line":750,"column":10},"end":{"line":750,"column":21}},"293":{"start":{"line":752,"column":10},"end":{"line":752,"column":21}},"294":{"start":{"line":754,"column":10},"end":{"line":754,"column":21}},"295":{"start":{"line":756,"column":10},"end":{"line":756,"column":21}},"296":{"start":{"line":766,"column":6},"end":{"line":766,"column":67}},"297":{"start":{"line":766,"column":60},"end":{"line":766,"column":67}},"298":{"start":{"line":767,"column":6},"end":{"line":767,"column":67}},"299":{"start":{"line":767,"column":60},"end":{"line":767,"column":67}},"300":{"start":{"line":769,"column":24},"end":{"line":769,"column":43}},"301":{"start":{"line":770,"column":25},"end":{"line":770,"column":55}},"302":{"start":{"line":771,"column":6},"end":{"line":771,"column":30}},"303":{"start":{"line":771,"column":23},"end":{"line":771,"column":30}},"304":{"start":{"line":773,"column":6},"end":{"line":773,"column":75}},"305":{"start":{"line":776,"column":29},"end":{"line":776,"column":66}},"306":{"start":{"line":777,"column":6},"end":{"line":779,"column":7}},"307":{"start":{"line":778,"column":8},"end":{"line":778,"column":81}},"308":{"start":{"line":782,"column":22},"end":{"line":782,"column":50}},"309":{"start":{"line":783,"column":6},"end":{"line":785,"column":7}},"310":{"start":{"line":784,"column":8},"end":{"line":784,"column":73}},"311":{"start":{"line":796,"column":6},"end":{"line":805,"column":9}},"312":{"start":{"line":797,"column":22},"end":{"line":797,"column":48}},"313":{"start":{"line":798,"column":8},"end":{"line":798,"column":27}},"314":{"start":{"line":798,"column":20},"end":{"line":798,"column":27}},"315":{"start":{"line":800,"column":8},"end":{"line":804,"column":9}},"316":{"start":{"line":801,"column":10},"end":{"line":801,"column":51}},"317":{"start":{"line":803,"column":10},"end":{"line":803,"column":50}},"318":{"start":{"line":814,"column":22},"end":{"line":814,"column":52}},"319":{"start":{"line":815,"column":6},"end":{"line":815,"column":32}},"320":{"start":{"line":815,"column":20},"end":{"line":815,"column":32}},"321":{"start":{"line":817,"column":18},"end":{"line":817,"column":86}},"322":{"start":{"line":818,"column":21},"end":{"line":818,"column":38}},"323":{"start":{"line":819,"column":6},"end":{"line":819,"column":39}},"324":{"start":{"line":819,"column":27},"end":{"line":819,"column":39}},"325":{"start":{"line":820,"column":6},"end":{"line":820,"column":27}},"326":{"start":{"line":828,"column":29},"end":{"line":828,"column":66}},"327":{"start":{"line":829,"column":27},"end":{"line":829,"column":68}},"328":{"start":{"line":830,"column":6},"end":{"line":830,"column":51}},"329":{"start":{"line":830,"column":44},"end":{"line":830,"column":51}},"330":{"start":{"line":832,"column":25},"end":{"line":832,"column":62}},"331":{"start":{"line":833,"column":6},"end":{"line":835,"column":7}},"332":{"start":{"line":834,"column":8},"end":{"line":834,"column":79}},"333":{"start":{"line":836,"column":6},"end":{"line":839,"column":8}},"334":{"start":{"line":847,"column":29},"end":{"line":847,"column":66}},"335":{"start":{"line":848,"column":27},"end":{"line":848,"column":68}},"336":{"start":{"line":849,"column":6},"end":{"line":849,"column":51}},"337":{"start":{"line":849,"column":44},"end":{"line":849,"column":51}},"338":{"start":{"line":851,"column":6},"end":{"line":851,"column":72}},"339":{"start":{"line":852,"column":6},"end":{"line":852,"column":70}},"340":{"start":{"line":862,"column":6},"end":{"line":869,"column":7}},"341":{"start":{"line":868,"column":8},"end":{"line":868,"column":17}},"342":{"start":{"line":872,"column":6},"end":{"line":872,"column":57}},"343":{"start":{"line":885,"column":20},"end":{"line":885,"column":46}},"344":{"start":{"line":886,"column":6},"end":{"line":886,"column":25}},"345":{"start":{"line":886,"column":18},"end":{"line":886,"column":25}},"346":{"start":{"line":889,"column":6},"end":{"line":895,"column":7}},"347":{"start":{"line":890,"column":31},"end":{"line":890,"column":55}},"348":{"start":{"line":891,"column":8},"end":{"line":894,"column":9}},"349":{"start":{"line":892,"column":10},"end":{"line":892,"column":49}},"350":{"start":{"line":893,"column":10},"end":{"line":893,"column":17}},"351":{"start":{"line":898,"column":6},"end":{"line":902,"column":7}},"352":{"start":{"line":899,"column":8},"end":{"line":899,"column":45}},"353":{"start":{"line":901,"column":8},"end":{"line":901,"column":43}},"354":{"start":{"line":904,"column":6},"end":{"line":904,"column":39}},"355":{"start":{"line":905,"column":6},"end":{"line":905,"column":47}},"356":{"start":{"line":906,"column":6},"end":{"line":906,"column":45}},"357":{"start":{"line":908,"column":18},"end":{"line":908,"column":54}},"358":{"start":{"line":909,"column":6},"end":{"line":920,"column":7}},"359":{"start":{"line":910,"column":8},"end":{"line":910,"column":51}},"360":{"start":{"line":911,"column":8},"end":{"line":914,"column":10}},"361":{"start":{"line":915,"column":8},"end":{"line":918,"column":10}},"362":{"start":{"line":919,"column":8},"end":{"line":919,"column":30}},"363":{"start":{"line":923,"column":6},"end":{"line":928,"column":7}},"364":{"start":{"line":924,"column":26},"end":{"line":924,"column":56}},"365":{"start":{"line":925,"column":8},"end":{"line":927,"column":9}},"366":{"start":{"line":926,"column":10},"end":{"line":926,"column":28}},"367":{"start":{"line":931,"column":6},"end":{"line":933,"column":7}},"368":{"start":{"line":932,"column":8},"end":{"line":932,"column":28}},"369":{"start":{"line":941,"column":20},"end":{"line":941,"column":46}},"370":{"start":{"line":942,"column":6},"end":{"line":942,"column":25}},"371":{"start":{"line":942,"column":18},"end":{"line":942,"column":25}},"372":{"start":{"line":945,"column":6},"end":{"line":945,"column":41}},"373":{"start":{"line":947,"column":6},"end":{"line":947,"column":42}},"374":{"start":{"line":948,"column":6},"end":{"line":948,"column":46}},"375":{"start":{"line":949,"column":6},"end":{"line":949,"column":45}},"376":{"start":{"line":951,"column":18},"end":{"line":951,"column":54}},"377":{"start":{"line":952,"column":6},"end":{"line":967,"column":7}},"378":{"start":{"line":953,"column":8},"end":{"line":953,"column":50}},"379":{"start":{"line":954,"column":8},"end":{"line":957,"column":10}},"380":{"start":{"line":958,"column":8},"end":{"line":961,"column":10}},"381":{"start":{"line":962,"column":8},"end":{"line":962,"column":30}},"382":{"start":{"line":964,"column":8},"end":{"line":966,"column":9}},"383":{"start":{"line":965,"column":10},"end":{"line":965,"column":22}},"384":{"start":{"line":970,"column":6},"end":{"line":972,"column":7}},"385":{"start":{"line":971,"column":8},"end":{"line":971,"column":28}},"386":{"start":{"line":981,"column":23},"end":{"line":981,"column":25}},"387":{"start":{"line":982,"column":6},"end":{"line":988,"column":9}},"388":{"start":{"line":983,"column":22},"end":{"line":983,"column":48}},"389":{"start":{"line":984,"column":8},"end":{"line":984,"column":27}},"390":{"start":{"line":984,"column":20},"end":{"line":984,"column":27}},"391":{"start":{"line":985,"column":8},"end":{"line":987,"column":9}},"392":{"start":{"line":986,"column":10},"end":{"line":986,"column":35}},"393":{"start":{"line":989,"column":6},"end":{"line":989,"column":22}},"394":{"start":{"line":998,"column":6},"end":{"line":1005,"column":7}},"395":{"start":{"line":999,"column":8},"end":{"line":1002,"column":9}},"396":{"start":{"line":1000,"column":10},"end":{"line":1000,"column":42}},"397":{"start":{"line":1001,"column":10},"end":{"line":1001,"column":17}},"398":{"start":{"line":1007,"column":6},"end":{"line":1013,"column":7}},"399":{"start":{"line":1008,"column":8},"end":{"line":1010,"column":9}},"400":{"start":{"line":1009,"column":10},"end":{"line":1009,"column":32}},"401":{"start":{"line":1021,"column":20},"end":{"line":1021,"column":46}},"402":{"start":{"line":1022,"column":6},"end":{"line":1022,"column":25}},"403":{"start":{"line":1022,"column":18},"end":{"line":1022,"column":25}},"404":{"start":{"line":1024,"column":6},"end":{"line":1028,"column":7}},"405":{"start":{"line":1025,"column":8},"end":{"line":1025,"column":36}},"406":{"start":{"line":1027,"column":8},"end":{"line":1027,"column":45}},"407":{"start":{"line":1035,"column":6},"end":{"line":1038,"column":7}},"408":{"start":{"line":1036,"column":8},"end":{"line":1036,"column":34}},"409":{"start":{"line":1037,"column":8},"end":{"line":1037,"column":15}},"410":{"start":{"line":1039,"column":6},"end":{"line":1039,"column":45}},"411":{"start":{"line":1052,"column":6},"end":{"line":1052,"column":89}},"412":{"start":{"line":1052,"column":82},"end":{"line":1052,"column":89}},"413":{"start":{"line":1054,"column":20},"end":{"line":1054,"column":37}},"414":{"start":{"line":1055,"column":6},"end":{"line":1055,"column":56}},"415":{"start":{"line":1055,"column":49},"end":{"line":1055,"column":56}},"416":{"start":{"line":1057,"column":35},"end":{"line":1057,"column":47}},"417":{"start":{"line":1058,"column":37},"end":{"line":1058,"column":48}},"418":{"start":{"line":1059,"column":35},"end":{"line":1059,"column":46}},"419":{"start":{"line":1061,"column":6},"end":{"line":1065,"column":7}},"420":{"start":{"line":1062,"column":8},"end":{"line":1062,"column":49}},"421":{"start":{"line":1064,"column":8},"end":{"line":1064,"column":44}},"422":{"start":{"line":1067,"column":6},"end":{"line":1071,"column":7}},"423":{"start":{"line":1068,"column":8},"end":{"line":1068,"column":51}},"424":{"start":{"line":1070,"column":8},"end":{"line":1070,"column":46}},"425":{"start":{"line":1073,"column":6},"end":{"line":1077,"column":7}},"426":{"start":{"line":1074,"column":8},"end":{"line":1074,"column":49}},"427":{"start":{"line":1076,"column":8},"end":{"line":1076,"column":44}},"428":{"start":{"line":1085,"column":20},"end":{"line":1085,"column":46}},"429":{"start":{"line":1086,"column":6},"end":{"line":1086,"column":25}},"430":{"start":{"line":1086,"column":18},"end":{"line":1086,"column":25}},"431":{"start":{"line":1089,"column":6},"end":{"line":1089,"column":72}},"432":{"start":{"line":1089,"column":65},"end":{"line":1089,"column":72}},"433":{"start":{"line":1092,"column":6},"end":{"line":1092,"column":75}},"434":{"start":{"line":1092,"column":68},"end":{"line":1092,"column":75}},"435":{"start":{"line":1094,"column":6},"end":{"line":1094,"column":42}},"436":{"start":{"line":1102,"column":20},"end":{"line":1102,"column":46}},"437":{"start":{"line":1103,"column":6},"end":{"line":1103,"column":25}},"438":{"start":{"line":1103,"column":18},"end":{"line":1103,"column":25}},"439":{"start":{"line":1105,"column":6},"end":{"line":1105,"column":72}},"440":{"start":{"line":1105,"column":65},"end":{"line":1105,"column":72}},"441":{"start":{"line":1107,"column":30},"end":{"line":1107,"column":85}},"442":{"start":{"line":1108,"column":6},"end":{"line":1108,"column":35}},"443":{"start":{"line":1108,"column":28},"end":{"line":1108,"column":35}},"444":{"start":{"line":1110,"column":6},"end":{"line":1112,"column":7}},"445":{"start":{"line":1111,"column":8},"end":{"line":1111,"column":42}},"446":{"start":{"line":1120,"column":6},"end":{"line":1120,"column":21}},"447":{"start":{"line":1120,"column":14},"end":{"line":1120,"column":21}},"448":{"start":{"line":1122,"column":18},"end":{"line":1122,"column":23}},"449":{"start":{"line":1123,"column":6},"end":{"line":1123,"column":62}},"450":{"start":{"line":1123,"column":55},"end":{"line":1123,"column":62}},"451":{"start":{"line":1125,"column":22},"end":{"line":1125,"column":37}},"452":{"start":{"line":1126,"column":24},"end":{"line":1126,"column":83}},"453":{"start":{"line":1127,"column":6},"end":{"line":1127,"column":29}},"454":{"start":{"line":1127,"column":22},"end":{"line":1127,"column":29}},"455":{"start":{"line":1129,"column":20},"end":{"line":1129,"column":46}},"456":{"start":{"line":1130,"column":6},"end":{"line":1130,"column":25}},"457":{"start":{"line":1130,"column":18},"end":{"line":1130,"column":25}},"458":{"start":{"line":1131,"column":6},"end":{"line":1131,"column":75}},"459":{"start":{"line":1131,"column":68},"end":{"line":1131,"column":75}},"460":{"start":{"line":1133,"column":19},"end":{"line":1133,"column":39}},"461":{"start":{"line":1134,"column":20},"end":{"line":1134,"column":54}},"462":{"start":{"line":1136,"column":6},"end":{"line":1136,"column":25}},"463":{"start":{"line":1138,"column":25},"end":{"line":1138,"column":67}},"464":{"start":{"line":1139,"column":24},"end":{"line":1139,"column":79}},"465":{"start":{"line":1140,"column":6},"end":{"line":1140,"column":46}},"466":{"start":{"line":1143,"column":6},"end":{"line":1145,"column":7}},"467":{"start":{"line":1144,"column":8},"end":{"line":1144,"column":39}},"468":{"start":{"line":1154,"column":6},"end":{"line":1154,"column":70}},"469":{"start":{"line":1154,"column":63},"end":{"line":1154,"column":70}},"470":{"start":{"line":1156,"column":22},"end":{"line":1156,"column":37}},"471":{"start":{"line":1157,"column":24},"end":{"line":1157,"column":83}},"472":{"start":{"line":1158,"column":6},"end":{"line":1158,"column":29}},"473":{"start":{"line":1158,"column":22},"end":{"line":1158,"column":29}},"474":{"start":{"line":1160,"column":20},"end":{"line":1160,"column":46}},"475":{"start":{"line":1161,"column":6},"end":{"line":1161,"column":25}},"476":{"start":{"line":1161,"column":18},"end":{"line":1161,"column":25}},"477":{"start":{"line":1163,"column":6},"end":{"line":1163,"column":56}},"478":{"start":{"line":1163,"column":49},"end":{"line":1163,"column":56}},"479":{"start":{"line":1165,"column":6},"end":{"line":1165,"column":25}},"480":{"start":{"line":1168,"column":6},"end":{"line":1170,"column":7}},"481":{"start":{"line":1169,"column":8},"end":{"line":1169,"column":55}},"482":{"start":{"line":1172,"column":6},"end":{"line":1172,"column":29}},"483":{"start":{"line":1173,"column":6},"end":{"line":1173,"column":40}},"484":{"start":{"line":1174,"column":6},"end":{"line":1174,"column":32}},"485":{"start":{"line":1175,"column":6},"end":{"line":1175,"column":36}},"486":{"start":{"line":1176,"column":6},"end":{"line":1178,"column":7}},"487":{"start":{"line":1177,"column":8},"end":{"line":1177,"column":52}},"488":{"start":{"line":1179,"column":6},"end":{"line":1179,"column":30}},"489":{"start":{"line":1180,"column":6},"end":{"line":1180,"column":38}},"490":{"start":{"line":1181,"column":6},"end":{"line":1181,"column":67}},"491":{"start":{"line":1183,"column":6},"end":{"line":1183,"column":61}},"492":{"start":{"line":1184,"column":6},"end":{"line":1184,"column":63}},"493":{"start":{"line":1186,"column":6},"end":{"line":1186,"column":58}},"494":{"start":{"line":1187,"column":6},"end":{"line":1187,"column":66}},"495":{"start":{"line":1195,"column":6},"end":{"line":1195,"column":83}},"496":{"start":{"line":1195,"column":76},"end":{"line":1195,"column":83}},"497":{"start":{"line":1197,"column":6},"end":{"line":1197,"column":38}},"498":{"start":{"line":1199,"column":6},"end":{"line":1199,"column":29}},"499":{"start":{"line":1199,"column":22},"end":{"line":1199,"column":29}},"500":{"start":{"line":1200,"column":6},"end":{"line":1222,"column":9}},"501":{"start":{"line":1201,"column":8},"end":{"line":1201,"column":23}},"502":{"start":{"line":1202,"column":8},"end":{"line":1202,"column":85}},"503":{"start":{"line":1202,"column":78},"end":{"line":1202,"column":85}},"504":{"start":{"line":1204,"column":8},"end":{"line":1221,"column":9}},"505":{"start":{"line":1205,"column":27},"end":{"line":1210,"column":11}},"506":{"start":{"line":1211,"column":10},"end":{"line":1211,"column":61}},"507":{"start":{"line":1214,"column":10},"end":{"line":1219,"column":11}},"508":{"start":{"line":1216,"column":12},"end":{"line":1216,"column":65}},"509":{"start":{"line":1220,"column":10},"end":{"line":1220,"column":28}},"510":{"start":{"line":1229,"column":6},"end":{"line":1229,"column":35}},"511":{"start":{"line":1229,"column":28},"end":{"line":1229,"column":35}},"512":{"start":{"line":1232,"column":6},"end":{"line":1252,"column":7}},"513":{"start":{"line":1233,"column":8},"end":{"line":1233,"column":41}},"514":{"start":{"line":1234,"column":8},"end":{"line":1234,"column":23}},"515":{"start":{"line":1236,"column":8},"end":{"line":1251,"column":9}},"516":{"start":{"line":1237,"column":29},"end":{"line":1242,"column":11}},"517":{"start":{"line":1243,"column":10},"end":{"line":1243,"column":63}},"518":{"start":{"line":1245,"column":10},"end":{"line":1250,"column":11}},"519":{"start":{"line":1247,"column":12},"end":{"line":1247,"column":75}},"520":{"start":{"line":1254,"column":6},"end":{"line":1254,"column":30}},"521":{"start":{"line":1255,"column":6},"end":{"line":1255,"column":31}},"522":{"start":{"line":1256,"column":6},"end":{"line":1256,"column":35}},"523":{"start":{"line":1258,"column":6},"end":{"line":1258,"column":64}},"524":{"start":{"line":1259,"column":6},"end":{"line":1259,"column":66}},"525":{"start":{"line":1260,"column":6},"end":{"line":1260,"column":61}},"526":{"start":{"line":1261,"column":6},"end":{"line":1261,"column":69}},"527":{"start":{"line":1264,"column":6},"end":{"line":1266,"column":7}},"528":{"start":{"line":1265,"column":8},"end":{"line":1265,"column":55}},"529":{"start":{"line":1267,"column":6},"end":{"line":1267,"column":33}},"530":{"start":{"line":1268,"column":6},"end":{"line":1270,"column":7}},"531":{"start":{"line":1269,"column":8},"end":{"line":1269,"column":58}},"532":{"start":{"line":1273,"column":6},"end":{"line":1275,"column":7}},"533":{"start":{"line":1274,"column":8},"end":{"line":1274,"column":39}},"534":{"start":{"line":1287,"column":20},"end":{"line":1287,"column":37}},"535":{"start":{"line":1288,"column":22},"end":{"line":1288,"column":40}},"536":{"start":{"line":1290,"column":23},"end":{"line":1290,"column":50}},"537":{"start":{"line":1291,"column":23},"end":{"line":1291,"column":50}},"538":{"start":{"line":1294,"column":6},"end":{"line":1294,"column":68}},"539":{"start":{"line":1297,"column":29},"end":{"line":1297,"column":53}},"540":{"start":{"line":1298,"column":29},"end":{"line":1298,"column":58}},"541":{"start":{"line":1299,"column":6},"end":{"line":1306,"column":7}},"542":{"start":{"line":1300,"column":33},"end":{"line":1300,"column":74}},"543":{"start":{"line":1301,"column":35},"end":{"line":1301,"column":85}},"544":{"start":{"line":1302,"column":8},"end":{"line":1305,"column":9}},"545":{"start":{"line":1303,"column":10},"end":{"line":1303,"column":62}},"546":{"start":{"line":1304,"column":10},"end":{"line":1304,"column":52}},"547":{"start":{"line":1307,"column":6},"end":{"line":1307,"column":23}},"548":{"start":{"line":1316,"column":18},"end":{"line":1316,"column":19}},"549":{"start":{"line":1317,"column":6},"end":{"line":1321,"column":9}},"550":{"start":{"line":1318,"column":8},"end":{"line":1318,"column":51}},"551":{"start":{"line":1318,"column":44},"end":{"line":1318,"column":51}},"552":{"start":{"line":1319,"column":22},"end":{"line":1319,"column":48}},"553":{"start":{"line":1320,"column":8},"end":{"line":1320,"column":60}},"554":{"start":{"line":1322,"column":6},"end":{"line":1322,"column":19}},"555":{"start":{"line":1330,"column":6},"end":{"line":1337,"column":7}},"556":{"start":{"line":1331,"column":21},"end":{"line":1333,"column":16}},"557":{"start":{"line":1334,"column":8},"end":{"line":1336,"column":9}},"558":{"start":{"line":1335,"column":10},"end":{"line":1335,"column":28}},"559":{"start":{"line":1338,"column":6},"end":{"line":1340,"column":7}},"560":{"start":{"line":1339,"column":8},"end":{"line":1339,"column":33}},"561":{"start":{"line":1341,"column":6},"end":{"line":1341,"column":15}},"562":{"start":{"line":1349,"column":18},"end":{"line":1351,"column":7}},"563":{"start":{"line":1352,"column":21},"end":{"line":1352,"column":38}},"564":{"start":{"line":1353,"column":6},"end":{"line":1353,"column":44}},"565":{"start":{"line":1363,"column":25},"end":{"line":1363,"column":55}},"566":{"start":{"line":1365,"column":8},"end":{"line":1367,"column":10}},"567":{"start":{"line":1368,"column":21},"end":{"line":1368,"column":38}},"568":{"start":{"line":1369,"column":6},"end":{"line":1369,"column":41}},"569":{"start":{"line":1369,"column":27},"end":{"line":1369,"column":41}},"570":{"start":{"line":1371,"column":6},"end":{"line":1381,"column":7}},"571":{"start":{"line":1372,"column":21},"end":{"line":1374,"column":16}},"572":{"start":{"line":1375,"column":8},"end":{"line":1377,"column":9}},"573":{"start":{"line":1376,"column":10},"end":{"line":1376,"column":28}},"574":{"start":{"line":1378,"column":8},"end":{"line":1380,"column":9}},"575":{"start":{"line":1379,"column":10},"end":{"line":1379,"column":42}},"576":{"start":{"line":1383,"column":6},"end":{"line":1383,"column":15}},"577":{"start":{"line":1392,"column":6},"end":{"line":1392,"column":59}},"578":{"start":{"line":1392,"column":47},"end":{"line":1392,"column":59}},"579":{"start":{"line":1393,"column":22},"end":{"line":1393,"column":34}},"580":{"start":{"line":1394,"column":6},"end":{"line":1394,"column":47}},"581":{"start":{"line":1394,"column":35},"end":{"line":1394,"column":47}},"582":{"start":{"line":1395,"column":18},"end":{"line":1395,"column":57}},"583":{"start":{"line":1396,"column":6},"end":{"line":1396,"column":47}},"584":{"start":{"line":1401,"column":2},"end":{"line":1401,"column":39}},"585":{"start":{"line":1404,"column":2},"end":{"line":1406,"column":3}},"586":{"start":{"line":1405,"column":4},"end":{"line":1405,"column":35}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":17,"column":1},"end":{"line":17,"column":2}},"loc":{"start":{"line":17,"column":19},"end":{"line":1407,"column":1}},"line":17},"1":{"name":"(anonymous_1)","decl":{"start":{"line":62,"column":4},"end":{"line":62,"column":5}},"loc":{"start":{"line":62,"column":18},"end":{"line":122,"column":5}},"line":62},"2":{"name":"(anonymous_2)","decl":{"start":{"line":119,"column":41},"end":{"line":119,"column":42}},"loc":{"start":{"line":119,"column":47},"end":{"line":119,"column":63}},"line":119},"3":{"name":"(anonymous_3)","decl":{"start":{"line":121,"column":49},"end":{"line":121,"column":50}},"loc":{"start":{"line":121,"column":55},"end":{"line":121,"column":80}},"line":121},"4":{"name":"(anonymous_4)","decl":{"start":{"line":128,"column":4},"end":{"line":128,"column":5}},"loc":{"start":{"line":128,"column":11},"end":{"line":198,"column":5}},"line":128},"5":{"name":"(anonymous_5)","decl":{"start":{"line":135,"column":17},"end":{"line":135,"column":18}},"loc":{"start":{"line":135,"column":28},"end":{"line":137,"column":9}},"line":135},"6":{"name":"(anonymous_6)","decl":{"start":{"line":141,"column":66},"end":{"line":141,"column":67}},"loc":{"start":{"line":141,"column":79},"end":{"line":143,"column":7}},"line":141},"7":{"name":"(anonymous_7)","decl":{"start":{"line":146,"column":28},"end":{"line":146,"column":29}},"loc":{"start":{"line":146,"column":41},"end":{"line":153,"column":7}},"line":146},"8":{"name":"(anonymous_8)","decl":{"start":{"line":156,"column":69},"end":{"line":156,"column":70}},"loc":{"start":{"line":156,"column":78},"end":{"line":159,"column":7}},"line":156},"9":{"name":"(anonymous_9)","decl":{"start":{"line":193,"column":28},"end":{"line":193,"column":29}},"loc":{"start":{"line":193,"column":34},"end":{"line":195,"column":7}},"line":193},"10":{"name":"(anonymous_10)","decl":{"start":{"line":204,"column":4},"end":{"line":204,"column":5}},"loc":{"start":{"line":204,"column":25},"end":{"line":227,"column":5}},"line":204},"11":{"name":"(anonymous_11)","decl":{"start":{"line":235,"column":4},"end":{"line":235,"column":5}},"loc":{"start":{"line":235,"column":26},"end":{"line":260,"column":5}},"line":235},"12":{"name":"(anonymous_12)","decl":{"start":{"line":239,"column":19},"end":{"line":239,"column":20}},"loc":{"start":{"line":239,"column":32},"end":{"line":245,"column":9}},"line":239},"13":{"name":"(anonymous_13)","decl":{"start":{"line":241,"column":31},"end":{"line":241,"column":32}},"loc":{"start":{"line":241,"column":37},"end":{"line":244,"column":11}},"line":241},"14":{"name":"(anonymous_14)","decl":{"start":{"line":246,"column":15},"end":{"line":246,"column":16}},"loc":{"start":{"line":246,"column":28},"end":{"line":252,"column":9}},"line":246},"15":{"name":"(anonymous_15)","decl":{"start":{"line":253,"column":16},"end":{"line":253,"column":17}},"loc":{"start":{"line":253,"column":22},"end":{"line":258,"column":9}},"line":253},"16":{"name":"(anonymous_16)","decl":{"start":{"line":267,"column":4},"end":{"line":267,"column":5}},"loc":{"start":{"line":267,"column":29},"end":{"line":299,"column":5}},"line":267},"17":{"name":"(anonymous_17)","decl":{"start":{"line":269,"column":47},"end":{"line":269,"column":48}},"loc":{"start":{"line":269,"column":62},"end":{"line":291,"column":7}},"line":269},"18":{"name":"(anonymous_18)","decl":{"start":{"line":315,"column":4},"end":{"line":315,"column":5}},"loc":{"start":{"line":315,"column":23},"end":{"line":356,"column":5}},"line":315},"19":{"name":"(anonymous_19)","decl":{"start":{"line":317,"column":26},"end":{"line":317,"column":27}},"loc":{"start":{"line":317,"column":37},"end":{"line":321,"column":9}},"line":317},"20":{"name":"(anonymous_20)","decl":{"start":{"line":362,"column":4},"end":{"line":362,"column":5}},"loc":{"start":{"line":362,"column":16},"end":{"line":368,"column":5}},"line":362},"21":{"name":"(anonymous_21)","decl":{"start":{"line":374,"column":4},"end":{"line":374,"column":5}},"loc":{"start":{"line":374,"column":19},"end":{"line":379,"column":5}},"line":374},"22":{"name":"(anonymous_22)","decl":{"start":{"line":390,"column":4},"end":{"line":390,"column":5}},"loc":{"start":{"line":390,"column":16},"end":{"line":406,"column":5}},"line":390},"23":{"name":"(anonymous_23)","decl":{"start":{"line":397,"column":28},"end":{"line":397,"column":29}},"loc":{"start":{"line":397,"column":34},"end":{"line":403,"column":7}},"line":397},"24":{"name":"(anonymous_24)","decl":{"start":{"line":412,"column":4},"end":{"line":412,"column":5}},"loc":{"start":{"line":412,"column":25},"end":{"line":440,"column":5}},"line":412},"25":{"name":"(anonymous_25)","decl":{"start":{"line":426,"column":23},"end":{"line":426,"column":24}},"loc":{"start":{"line":426,"column":29},"end":{"line":432,"column":9}},"line":426},"26":{"name":"(anonymous_26)","decl":{"start":{"line":446,"column":4},"end":{"line":446,"column":5}},"loc":{"start":{"line":446,"column":31},"end":{"line":469,"column":5}},"line":446},"27":{"name":"(anonymous_27)","decl":{"start":{"line":461,"column":19},"end":{"line":461,"column":20}},"loc":{"start":{"line":461,"column":25},"end":{"line":467,"column":9}},"line":461},"28":{"name":"(anonymous_28)","decl":{"start":{"line":481,"column":4},"end":{"line":481,"column":5}},"loc":{"start":{"line":481,"column":18},"end":{"line":520,"column":5}},"line":481},"29":{"name":"(anonymous_29)","decl":{"start":{"line":498,"column":34},"end":{"line":498,"column":35}},"loc":{"start":{"line":498,"column":40},"end":{"line":517,"column":7}},"line":498},"30":{"name":"(anonymous_30)","decl":{"start":{"line":499,"column":49},"end":{"line":499,"column":50}},"loc":{"start":{"line":499,"column":64},"end":{"line":512,"column":9}},"line":499},"31":{"name":"(anonymous_31)","decl":{"start":{"line":526,"column":4},"end":{"line":526,"column":5}},"loc":{"start":{"line":526,"column":28},"end":{"line":556,"column":5}},"line":526},"32":{"name":"(anonymous_32)","decl":{"start":{"line":530,"column":47},"end":{"line":530,"column":48}},"loc":{"start":{"line":530,"column":62},"end":{"line":555,"column":7}},"line":530},"33":{"name":"(anonymous_33)","decl":{"start":{"line":563,"column":4},"end":{"line":563,"column":5}},"loc":{"start":{"line":563,"column":52},"end":{"line":582,"column":5}},"line":563},"34":{"name":"(anonymous_34)","decl":{"start":{"line":589,"column":4},"end":{"line":589,"column":5}},"loc":{"start":{"line":589,"column":35},"end":{"line":600,"column":5}},"line":589},"35":{"name":"(anonymous_35)","decl":{"start":{"line":606,"column":4},"end":{"line":606,"column":5}},"loc":{"start":{"line":606,"column":29},"end":{"line":613,"column":5}},"line":606},"36":{"name":"(anonymous_36)","decl":{"start":{"line":619,"column":4},"end":{"line":619,"column":5}},"loc":{"start":{"line":619,"column":32},"end":{"line":659,"column":5}},"line":619},"37":{"name":"(anonymous_37)","decl":{"start":{"line":622,"column":47},"end":{"line":622,"column":48}},"loc":{"start":{"line":622,"column":62},"end":{"line":658,"column":7}},"line":622},"38":{"name":"(anonymous_38)","decl":{"start":{"line":665,"column":4},"end":{"line":665,"column":5}},"loc":{"start":{"line":665,"column":24},"end":{"line":672,"column":5}},"line":665},"39":{"name":"(anonymous_39)","decl":{"start":{"line":678,"column":4},"end":{"line":678,"column":5}},"loc":{"start":{"line":678,"column":26},"end":{"line":688,"column":5}},"line":678},"40":{"name":"(anonymous_40)","decl":{"start":{"line":686,"column":24},"end":{"line":686,"column":25}},"loc":{"start":{"line":686,"column":30},"end":{"line":686,"column":32}},"line":686},"41":{"name":"(anonymous_41)","decl":{"start":{"line":694,"column":4},"end":{"line":694,"column":5}},"loc":{"start":{"line":694,"column":38},"end":{"line":710,"column":5}},"line":694},"42":{"name":"(anonymous_42)","decl":{"start":{"line":698,"column":47},"end":{"line":698,"column":48}},"loc":{"start":{"line":698,"column":62},"end":{"line":709,"column":7}},"line":698},"43":{"name":"(anonymous_43)","decl":{"start":{"line":716,"column":4},"end":{"line":716,"column":5}},"loc":{"start":{"line":716,"column":33},"end":{"line":722,"column":5}},"line":716},"44":{"name":"(anonymous_44)","decl":{"start":{"line":729,"column":4},"end":{"line":729,"column":5}},"loc":{"start":{"line":729,"column":27},"end":{"line":740,"column":5}},"line":729},"45":{"name":"(anonymous_45)","decl":{"start":{"line":747,"column":4},"end":{"line":747,"column":5}},"loc":{"start":{"line":747,"column":27},"end":{"line":758,"column":5}},"line":747},"46":{"name":"(anonymous_46)","decl":{"start":{"line":765,"column":4},"end":{"line":765,"column":5}},"loc":{"start":{"line":765,"column":35},"end":{"line":786,"column":5}},"line":765},"47":{"name":"(anonymous_47)","decl":{"start":{"line":795,"column":4},"end":{"line":795,"column":5}},"loc":{"start":{"line":795,"column":30},"end":{"line":806,"column":5}},"line":795},"48":{"name":"(anonymous_48)","decl":{"start":{"line":796,"column":47},"end":{"line":796,"column":48}},"loc":{"start":{"line":796,"column":62},"end":{"line":805,"column":7}},"line":796},"49":{"name":"(anonymous_49)","decl":{"start":{"line":813,"column":4},"end":{"line":813,"column":5}},"loc":{"start":{"line":813,"column":37},"end":{"line":821,"column":5}},"line":813},"50":{"name":"(anonymous_50)","decl":{"start":{"line":827,"column":4},"end":{"line":827,"column":5}},"loc":{"start":{"line":827,"column":39},"end":{"line":840,"column":5}},"line":827},"51":{"name":"(anonymous_51)","decl":{"start":{"line":846,"column":4},"end":{"line":846,"column":5}},"loc":{"start":{"line":846,"column":40},"end":{"line":853,"column":5}},"line":846},"52":{"name":"(anonymous_52)","decl":{"start":{"line":861,"column":4},"end":{"line":861,"column":5}},"loc":{"start":{"line":861,"column":49},"end":{"line":873,"column":5}},"line":861},"53":{"name":"(anonymous_53)","decl":{"start":{"line":884,"column":4},"end":{"line":884,"column":5}},"loc":{"start":{"line":884,"column":43},"end":{"line":934,"column":5}},"line":884},"54":{"name":"(anonymous_54)","decl":{"start":{"line":940,"column":4},"end":{"line":940,"column":5}},"loc":{"start":{"line":940,"column":41},"end":{"line":973,"column":5}},"line":940},"55":{"name":"(anonymous_55)","decl":{"start":{"line":979,"column":4},"end":{"line":979,"column":5}},"loc":{"start":{"line":979,"column":24},"end":{"line":990,"column":5}},"line":979},"56":{"name":"(anonymous_56)","decl":{"start":{"line":982,"column":47},"end":{"line":982,"column":48}},"loc":{"start":{"line":982,"column":62},"end":{"line":988,"column":7}},"line":982},"57":{"name":"(anonymous_57)","decl":{"start":{"line":997,"column":4},"end":{"line":997,"column":5}},"loc":{"start":{"line":997,"column":38},"end":{"line":1014,"column":5}},"line":997},"58":{"name":"(anonymous_58)","decl":{"start":{"line":1020,"column":4},"end":{"line":1020,"column":5}},"loc":{"start":{"line":1020,"column":27},"end":{"line":1029,"column":5}},"line":1020},"59":{"name":"(anonymous_59)","decl":{"start":{"line":1034,"column":4},"end":{"line":1034,"column":5}},"loc":{"start":{"line":1034,"column":21},"end":{"line":1040,"column":5}},"line":1034},"60":{"name":"(anonymous_60)","decl":{"start":{"line":1051,"column":4},"end":{"line":1051,"column":5}},"loc":{"start":{"line":1051,"column":25},"end":{"line":1078,"column":5}},"line":1051},"61":{"name":"(anonymous_61)","decl":{"start":{"line":1084,"column":4},"end":{"line":1084,"column":5}},"loc":{"start":{"line":1084,"column":41},"end":{"line":1095,"column":5}},"line":1084},"62":{"name":"(anonymous_62)","decl":{"start":{"line":1101,"column":4},"end":{"line":1101,"column":5}},"loc":{"start":{"line":1101,"column":36},"end":{"line":1113,"column":5}},"line":1101},"63":{"name":"(anonymous_63)","decl":{"start":{"line":1119,"column":4},"end":{"line":1119,"column":5}},"loc":{"start":{"line":1119,"column":24},"end":{"line":1146,"column":5}},"line":1119},"64":{"name":"(anonymous_64)","decl":{"start":{"line":1152,"column":4},"end":{"line":1152,"column":5}},"loc":{"start":{"line":1152,"column":19},"end":{"line":1188,"column":5}},"line":1152},"65":{"name":"(anonymous_65)","decl":{"start":{"line":1194,"column":4},"end":{"line":1194,"column":5}},"loc":{"start":{"line":1194,"column":14},"end":{"line":1223,"column":5}},"line":1194},"66":{"name":"(anonymous_66)","decl":{"start":{"line":1200,"column":41},"end":{"line":1200,"column":42}},"loc":{"start":{"line":1200,"column":47},"end":{"line":1222,"column":7}},"line":1200},"67":{"name":"(anonymous_67)","decl":{"start":{"line":1228,"column":4},"end":{"line":1228,"column":5}},"loc":{"start":{"line":1228,"column":17},"end":{"line":1276,"column":5}},"line":1228},"68":{"name":"(anonymous_68)","decl":{"start":{"line":1286,"column":4},"end":{"line":1286,"column":5}},"loc":{"start":{"line":1286,"column":63},"end":{"line":1308,"column":5}},"line":1286},"69":{"name":"(anonymous_69)","decl":{"start":{"line":1315,"column":4},"end":{"line":1315,"column":5}},"loc":{"start":{"line":1315,"column":48},"end":{"line":1323,"column":5}},"line":1315},"70":{"name":"(anonymous_70)","decl":{"start":{"line":1317,"column":47},"end":{"line":1317,"column":48}},"loc":{"start":{"line":1317,"column":62},"end":{"line":1321,"column":7}},"line":1317},"71":{"name":"(anonymous_71)","decl":{"start":{"line":1329,"column":4},"end":{"line":1329,"column":5}},"loc":{"start":{"line":1329,"column":24},"end":{"line":1342,"column":5}},"line":1329},"72":{"name":"(anonymous_72)","decl":{"start":{"line":1348,"column":4},"end":{"line":1348,"column":5}},"loc":{"start":{"line":1348,"column":29},"end":{"line":1354,"column":5}},"line":1348},"73":{"name":"(anonymous_73)","decl":{"start":{"line":1362,"column":4},"end":{"line":1362,"column":5}},"loc":{"start":{"line":1362,"column":43},"end":{"line":1384,"column":5}},"line":1362},"74":{"name":"(anonymous_74)","decl":{"start":{"line":1391,"column":4},"end":{"line":1391,"column":5}},"loc":{"start":{"line":1391,"column":19},"end":{"line":1397,"column":5}},"line":1391}},"branchMap":{"0":{"loc":{"start":{"line":166,"column":6},"end":{"line":172,"column":7}},"type":"if","locations":[{"start":{"line":166,"column":6},"end":{"line":172,"column":7}},{"start":{},"end":{}}],"line":166},"1":{"loc":{"start":{"line":175,"column":6},"end":{"line":181,"column":7}},"type":"if","locations":[{"start":{"line":175,"column":6},"end":{"line":181,"column":7}},{"start":{},"end":{}}],"line":175},"2":{"loc":{"start":{"line":175,"column":10},"end":{"line":175,"column":65}},"type":"binary-expr","locations":[{"start":{"line":175,"column":10},"end":{"line":175,"column":16}},{"start":{"line":175,"column":20},"end":{"line":175,"column":65}}],"line":175},"3":{"loc":{"start":{"line":176,"column":8},"end":{"line":178,"column":9}},"type":"if","locations":[{"start":{"line":176,"column":8},"end":{"line":178,"column":9}},{"start":{},"end":{}}],"line":176},"4":{"loc":{"start":{"line":184,"column":6},"end":{"line":190,"column":7}},"type":"if","locations":[{"start":{"line":184,"column":6},"end":{"line":190,"column":7}},{"start":{},"end":{}}],"line":184},"5":{"loc":{"start":{"line":184,"column":10},"end":{"line":184,"column":65}},"type":"binary-expr","locations":[{"start":{"line":184,"column":10},"end":{"line":184,"column":16}},{"start":{"line":184,"column":20},"end":{"line":184,"column":65}}],"line":184},"6":{"loc":{"start":{"line":185,"column":8},"end":{"line":187,"column":9}},"type":"if","locations":[{"start":{"line":185,"column":8},"end":{"line":187,"column":9}},{"start":{},"end":{}}],"line":185},"7":{"loc":{"start":{"line":206,"column":8},"end":{"line":208,"column":9}},"type":"if","locations":[{"start":{"line":206,"column":8},"end":{"line":208,"column":9}},{"start":{},"end":{}}],"line":206},"8":{"loc":{"start":{"line":206,"column":12},"end":{"line":206,"column":63}},"type":"binary-expr","locations":[{"start":{"line":206,"column":12},"end":{"line":206,"column":18}},{"start":{"line":206,"column":22},"end":{"line":206,"column":63}}],"line":206},"9":{"loc":{"start":{"line":211,"column":8},"end":{"line":217,"column":9}},"type":"if","locations":[{"start":{"line":211,"column":8},"end":{"line":217,"column":9}},{"start":{},"end":{}}],"line":211},"10":{"loc":{"start":{"line":214,"column":10},"end":{"line":216,"column":11}},"type":"if","locations":[{"start":{"line":214,"column":10},"end":{"line":216,"column":11}},{"start":{},"end":{}}],"line":214},"11":{"loc":{"start":{"line":240,"column":10},"end":{"line":240,"column":45}},"type":"if","locations":[{"start":{"line":240,"column":10},"end":{"line":240,"column":45}},{"start":{},"end":{}}],"line":240},"12":{"loc":{"start":{"line":247,"column":10},"end":{"line":250,"column":11}},"type":"if","locations":[{"start":{"line":247,"column":10},"end":{"line":250,"column":11}},{"start":{},"end":{}}],"line":247},"13":{"loc":{"start":{"line":254,"column":10},"end":{"line":257,"column":11}},"type":"if","locations":[{"start":{"line":254,"column":10},"end":{"line":257,"column":11}},{"start":{},"end":{}}],"line":254},"14":{"loc":{"start":{"line":275,"column":10},"end":{"line":277,"column":12}},"type":"binary-expr","locations":[{"start":{"line":275,"column":11},"end":{"line":275,"column":19}},{"start":{"line":276,"column":12},"end":{"line":276,"column":81}},{"start":{"line":277,"column":10},"end":{"line":277,"column":12}}],"line":275},"15":{"loc":{"start":{"line":282,"column":25},"end":{"line":282,"column":88}},"type":"cond-expr","locations":[{"start":{"line":282,"column":48},"end":{"line":282,"column":59}},{"start":{"line":282,"column":62},"end":{"line":282,"column":88}}],"line":282},"16":{"loc":{"start":{"line":286,"column":10},"end":{"line":288,"column":48}},"type":"binary-expr","locations":[{"start":{"line":286,"column":10},"end":{"line":286,"column":17}},{"start":{"line":287,"column":10},"end":{"line":287,"column":47}},{"start":{"line":288,"column":10},"end":{"line":288,"column":48}}],"line":286},"17":{"loc":{"start":{"line":295,"column":16},"end":{"line":295,"column":33}},"type":"binary-expr","locations":[{"start":{"line":295,"column":16},"end":{"line":295,"column":22}},{"start":{"line":295,"column":26},"end":{"line":295,"column":33}}],"line":295},"18":{"loc":{"start":{"line":318,"column":10},"end":{"line":318,"column":65}},"type":"if","locations":[{"start":{"line":318,"column":10},"end":{"line":318,"column":65}},{"start":{},"end":{}}],"line":318},"19":{"loc":{"start":{"line":318,"column":14},"end":{"line":318,"column":51}},"type":"binary-expr","locations":[{"start":{"line":318,"column":14},"end":{"line":318,"column":28}},{"start":{"line":318,"column":32},"end":{"line":318,"column":51}}],"line":318},"20":{"loc":{"start":{"line":320,"column":17},"end":{"line":320,"column":35}},"type":"cond-expr","locations":[{"start":{"line":320,"column":24},"end":{"line":320,"column":28}},{"start":{"line":320,"column":31},"end":{"line":320,"column":35}}],"line":320},"21":{"loc":{"start":{"line":324,"column":8},"end":{"line":345,"column":9}},"type":"if","locations":[{"start":{"line":324,"column":8},"end":{"line":345,"column":9}},{"start":{},"end":{}}],"line":324},"22":{"loc":{"start":{"line":326,"column":12},"end":{"line":326,"column":88}},"type":"cond-expr","locations":[{"start":{"line":326,"column":53},"end":{"line":326,"column":81}},{"start":{"line":326,"column":84},"end":{"line":326,"column":88}}],"line":326},"23":{"loc":{"start":{"line":326,"column":12},"end":{"line":326,"column":50}},"type":"binary-expr","locations":[{"start":{"line":326,"column":12},"end":{"line":326,"column":25}},{"start":{"line":326,"column":29},"end":{"line":326,"column":50}}],"line":326},"24":{"loc":{"start":{"line":328,"column":10},"end":{"line":328,"column":56}},"type":"if","locations":[{"start":{"line":328,"column":10},"end":{"line":328,"column":56}},{"start":{},"end":{}}],"line":328},"25":{"loc":{"start":{"line":331,"column":12},"end":{"line":333,"column":20}},"type":"cond-expr","locations":[{"start":{"line":332,"column":16},"end":{"line":332,"column":55}},{"start":{"line":333,"column":16},"end":{"line":333,"column":20}}],"line":331},"26":{"loc":{"start":{"line":331,"column":12},"end":{"line":331,"column":72}},"type":"binary-expr","locations":[{"start":{"line":331,"column":12},"end":{"line":331,"column":36}},{"start":{"line":331,"column":40},"end":{"line":331,"column":72}}],"line":331},"27":{"loc":{"start":{"line":335,"column":10},"end":{"line":335,"column":56}},"type":"if","locations":[{"start":{"line":335,"column":10},"end":{"line":335,"column":56}},{"start":{},"end":{}}],"line":335},"28":{"loc":{"start":{"line":339,"column":12},"end":{"line":341,"column":20}},"type":"cond-expr","locations":[{"start":{"line":340,"column":16},"end":{"line":340,"column":70}},{"start":{"line":341,"column":16},"end":{"line":341,"column":20}}],"line":339},"29":{"loc":{"start":{"line":342,"column":25},"end":{"line":342,"column":67}},"type":"cond-expr","locations":[{"start":{"line":342,"column":32},"end":{"line":342,"column":60}},{"start":{"line":342,"column":63},"end":{"line":342,"column":67}}],"line":342},"30":{"loc":{"start":{"line":344,"column":10},"end":{"line":344,"column":56}},"type":"if","locations":[{"start":{"line":344,"column":10},"end":{"line":344,"column":56}},{"start":{},"end":{}}],"line":344},"31":{"loc":{"start":{"line":348,"column":25},"end":{"line":348,"column":61}},"type":"cond-expr","locations":[{"start":{"line":348,"column":34},"end":{"line":348,"column":54}},{"start":{"line":348,"column":57},"end":{"line":348,"column":61}}],"line":348},"32":{"loc":{"start":{"line":350,"column":8},"end":{"line":350,"column":58}},"type":"if","locations":[{"start":{"line":350,"column":8},"end":{"line":350,"column":58}},{"start":{},"end":{}}],"line":350},"33":{"loc":{"start":{"line":363,"column":6},"end":{"line":363,"column":91}},"type":"if","locations":[{"start":{"line":363,"column":6},"end":{"line":363,"column":91}},{"start":{},"end":{}}],"line":363},"34":{"loc":{"start":{"line":363,"column":10},"end":{"line":363,"column":76}},"type":"binary-expr","locations":[{"start":{"line":363,"column":10},"end":{"line":363,"column":28}},{"start":{"line":363,"column":32},"end":{"line":363,"column":76}}],"line":363},"35":{"loc":{"start":{"line":365,"column":21},"end":{"line":365,"column":55}},"type":"binary-expr","locations":[{"start":{"line":365,"column":21},"end":{"line":365,"column":44}},{"start":{"line":365,"column":48},"end":{"line":365,"column":55}}],"line":365},"36":{"loc":{"start":{"line":375,"column":6},"end":{"line":375,"column":55}},"type":"if","locations":[{"start":{"line":375,"column":6},"end":{"line":375,"column":55}},{"start":{},"end":{}}],"line":375},"37":{"loc":{"start":{"line":391,"column":6},"end":{"line":391,"column":91}},"type":"if","locations":[{"start":{"line":391,"column":6},"end":{"line":391,"column":91}},{"start":{},"end":{}}],"line":391},"38":{"loc":{"start":{"line":391,"column":10},"end":{"line":391,"column":76}},"type":"binary-expr","locations":[{"start":{"line":391,"column":10},"end":{"line":391,"column":28}},{"start":{"line":391,"column":32},"end":{"line":391,"column":76}}],"line":391},"39":{"loc":{"start":{"line":393,"column":21},"end":{"line":393,"column":55}},"type":"binary-expr","locations":[{"start":{"line":393,"column":21},"end":{"line":393,"column":44}},{"start":{"line":393,"column":48},"end":{"line":393,"column":55}}],"line":393},"40":{"loc":{"start":{"line":395,"column":6},"end":{"line":395,"column":48}},"type":"if","locations":[{"start":{"line":395,"column":6},"end":{"line":395,"column":48}},{"start":{},"end":{}}],"line":395},"41":{"loc":{"start":{"line":395,"column":10},"end":{"line":395,"column":33}},"type":"binary-expr","locations":[{"start":{"line":395,"column":10},"end":{"line":395,"column":16}},{"start":{"line":395,"column":20},"end":{"line":395,"column":33}}],"line":395},"42":{"loc":{"start":{"line":414,"column":8},"end":{"line":416,"column":16}},"type":"cond-expr","locations":[{"start":{"line":415,"column":12},"end":{"line":415,"column":36}},{"start":{"line":416,"column":12},"end":{"line":416,"column":16}}],"line":414},"43":{"loc":{"start":{"line":414,"column":8},"end":{"line":414,"column":67}},"type":"binary-expr","locations":[{"start":{"line":414,"column":8},"end":{"line":414,"column":39}},{"start":{"line":414,"column":43},"end":{"line":414,"column":67}}],"line":414},"44":{"loc":{"start":{"line":417,"column":6},"end":{"line":420,"column":7}},"type":"if","locations":[{"start":{"line":417,"column":6},"end":{"line":420,"column":7}},{"start":{},"end":{}}],"line":417},"45":{"loc":{"start":{"line":417,"column":10},"end":{"line":417,"column":34}},"type":"binary-expr","locations":[{"start":{"line":417,"column":10},"end":{"line":417,"column":15}},{"start":{"line":417,"column":19},"end":{"line":417,"column":34}}],"line":417},"46":{"loc":{"start":{"line":434,"column":8},"end":{"line":438,"column":9}},"type":"if","locations":[{"start":{"line":434,"column":8},"end":{"line":438,"column":9}},{"start":{"line":436,"column":15},"end":{"line":438,"column":9}}],"line":434},"47":{"loc":{"start":{"line":448,"column":8},"end":{"line":450,"column":16}},"type":"cond-expr","locations":[{"start":{"line":449,"column":12},"end":{"line":449,"column":36}},{"start":{"line":450,"column":12},"end":{"line":450,"column":16}}],"line":448},"48":{"loc":{"start":{"line":448,"column":8},"end":{"line":448,"column":67}},"type":"binary-expr","locations":[{"start":{"line":448,"column":8},"end":{"line":448,"column":39}},{"start":{"line":448,"column":43},"end":{"line":448,"column":67}}],"line":448},"49":{"loc":{"start":{"line":451,"column":6},"end":{"line":454,"column":7}},"type":"if","locations":[{"start":{"line":451,"column":6},"end":{"line":454,"column":7}},{"start":{},"end":{}}],"line":451},"50":{"loc":{"start":{"line":451,"column":10},"end":{"line":451,"column":34}},"type":"binary-expr","locations":[{"start":{"line":451,"column":10},"end":{"line":451,"column":15}},{"start":{"line":451,"column":19},"end":{"line":451,"column":34}}],"line":451},"51":{"loc":{"start":{"line":483,"column":6},"end":{"line":485,"column":7}},"type":"if","locations":[{"start":{"line":483,"column":6},"end":{"line":485,"column":7}},{"start":{},"end":{}}],"line":483},"52":{"loc":{"start":{"line":483,"column":10},"end":{"line":483,"column":79}},"type":"binary-expr","locations":[{"start":{"line":483,"column":10},"end":{"line":483,"column":28}},{"start":{"line":483,"column":32},"end":{"line":483,"column":79}}],"line":483},"53":{"loc":{"start":{"line":487,"column":21},"end":{"line":487,"column":55}},"type":"binary-expr","locations":[{"start":{"line":487,"column":21},"end":{"line":487,"column":44}},{"start":{"line":487,"column":48},"end":{"line":487,"column":55}}],"line":487},"54":{"loc":{"start":{"line":489,"column":8},"end":{"line":491,"column":9}},"type":"if","locations":[{"start":{"line":489,"column":8},"end":{"line":491,"column":9}},{"start":{},"end":{}}],"line":489},"55":{"loc":{"start":{"line":489,"column":12},"end":{"line":489,"column":78}},"type":"binary-expr","locations":[{"start":{"line":489,"column":12},"end":{"line":489,"column":29}},{"start":{"line":489,"column":33},"end":{"line":489,"column":78}}],"line":489},"56":{"loc":{"start":{"line":501,"column":10},"end":{"line":501,"column":29}},"type":"if","locations":[{"start":{"line":501,"column":10},"end":{"line":501,"column":29}},{"start":{},"end":{}}],"line":501},"57":{"loc":{"start":{"line":527,"column":21},"end":{"line":527,"column":64}},"type":"cond-expr","locations":[{"start":{"line":527,"column":45},"end":{"line":527,"column":57}},{"start":{"line":527,"column":60},"end":{"line":527,"column":64}}],"line":527},"58":{"loc":{"start":{"line":527,"column":21},"end":{"line":527,"column":42}},"type":"binary-expr","locations":[{"start":{"line":527,"column":21},"end":{"line":527,"column":26}},{"start":{"line":527,"column":30},"end":{"line":527,"column":42}}],"line":527},"59":{"loc":{"start":{"line":528,"column":6},"end":{"line":528,"column":26}},"type":"if","locations":[{"start":{"line":528,"column":6},"end":{"line":528,"column":26}},{"start":{},"end":{}}],"line":528},"60":{"loc":{"start":{"line":533,"column":8},"end":{"line":533,"column":42}},"type":"if","locations":[{"start":{"line":533,"column":8},"end":{"line":533,"column":42}},{"start":{},"end":{}}],"line":533},"61":{"loc":{"start":{"line":533,"column":12},"end":{"line":533,"column":33}},"type":"binary-expr","locations":[{"start":{"line":533,"column":12},"end":{"line":533,"column":18}},{"start":{"line":533,"column":22},"end":{"line":533,"column":33}}],"line":533},"62":{"loc":{"start":{"line":537,"column":8},"end":{"line":542,"column":9}},"type":"if","locations":[{"start":{"line":537,"column":8},"end":{"line":542,"column":9}},{"start":{},"end":{}}],"line":537},"63":{"loc":{"start":{"line":548,"column":8},"end":{"line":554,"column":9}},"type":"if","locations":[{"start":{"line":548,"column":8},"end":{"line":554,"column":9}},{"start":{"line":551,"column":15},"end":{"line":554,"column":9}}],"line":548},"64":{"loc":{"start":{"line":565,"column":6},"end":{"line":565,"column":25}},"type":"if","locations":[{"start":{"line":565,"column":6},"end":{"line":565,"column":25}},{"start":{},"end":{}}],"line":565},"65":{"loc":{"start":{"line":568,"column":6},"end":{"line":568,"column":23}},"type":"if","locations":[{"start":{"line":568,"column":6},"end":{"line":568,"column":23}},{"start":{},"end":{}}],"line":568},"66":{"loc":{"start":{"line":570,"column":6},"end":{"line":576,"column":7}},"type":"if","locations":[{"start":{"line":570,"column":6},"end":{"line":576,"column":7}},{"start":{},"end":{}}],"line":570},"67":{"loc":{"start":{"line":590,"column":6},"end":{"line":599,"column":7}},"type":"switch","locations":[{"start":{"line":591,"column":8},"end":{"line":592,"column":22}},{"start":{"line":593,"column":8},"end":{"line":594,"column":22}},{"start":{"line":595,"column":8},"end":{"line":596,"column":24}},{"start":{"line":597,"column":8},"end":{"line":598,"column":22}}],"line":590},"68":{"loc":{"start":{"line":607,"column":18},"end":{"line":607,"column":63}},"type":"cond-expr","locations":[{"start":{"line":607,"column":41},"end":{"line":607,"column":56}},{"start":{"line":607,"column":59},"end":{"line":607,"column":63}}],"line":607},"69":{"loc":{"start":{"line":607,"column":18},"end":{"line":607,"column":38}},"type":"binary-expr","locations":[{"start":{"line":607,"column":18},"end":{"line":607,"column":19}},{"start":{"line":607,"column":23},"end":{"line":607,"column":38}}],"line":607},"70":{"loc":{"start":{"line":608,"column":24},"end":{"line":608,"column":69}},"type":"cond-expr","locations":[{"start":{"line":608,"column":45},"end":{"line":608,"column":62}},{"start":{"line":608,"column":65},"end":{"line":608,"column":69}}],"line":608},"71":{"loc":{"start":{"line":608,"column":24},"end":{"line":608,"column":42}},"type":"binary-expr","locations":[{"start":{"line":608,"column":24},"end":{"line":608,"column":27}},{"start":{"line":608,"column":31},"end":{"line":608,"column":42}}],"line":608},"72":{"loc":{"start":{"line":609,"column":6},"end":{"line":609,"column":29}},"type":"if","locations":[{"start":{"line":609,"column":6},"end":{"line":609,"column":29}},{"start":{},"end":{}}],"line":609},"73":{"loc":{"start":{"line":624,"column":8},"end":{"line":624,"column":27}},"type":"if","locations":[{"start":{"line":624,"column":8},"end":{"line":624,"column":27}},{"start":{},"end":{}}],"line":624},"74":{"loc":{"start":{"line":629,"column":8},"end":{"line":650,"column":9}},"type":"if","locations":[{"start":{"line":629,"column":8},"end":{"line":650,"column":9}},{"start":{},"end":{}}],"line":629},"75":{"loc":{"start":{"line":666,"column":24},"end":{"line":666,"column":69}},"type":"cond-expr","locations":[{"start":{"line":666,"column":47},"end":{"line":666,"column":62}},{"start":{"line":666,"column":65},"end":{"line":666,"column":69}}],"line":666},"76":{"loc":{"start":{"line":666,"column":24},"end":{"line":666,"column":44}},"type":"binary-expr","locations":[{"start":{"line":666,"column":24},"end":{"line":666,"column":25}},{"start":{"line":666,"column":29},"end":{"line":666,"column":44}}],"line":666},"77":{"loc":{"start":{"line":667,"column":24},"end":{"line":667,"column":87}},"type":"cond-expr","locations":[{"start":{"line":667,"column":57},"end":{"line":667,"column":80}},{"start":{"line":667,"column":83},"end":{"line":667,"column":87}}],"line":667},"78":{"loc":{"start":{"line":667,"column":24},"end":{"line":667,"column":54}},"type":"binary-expr","locations":[{"start":{"line":667,"column":24},"end":{"line":667,"column":33}},{"start":{"line":667,"column":37},"end":{"line":667,"column":54}}],"line":667},"79":{"loc":{"start":{"line":668,"column":6},"end":{"line":668,"column":29}},"type":"if","locations":[{"start":{"line":668,"column":6},"end":{"line":668,"column":29}},{"start":{},"end":{}}],"line":668},"80":{"loc":{"start":{"line":679,"column":6},"end":{"line":679,"column":21}},"type":"if","locations":[{"start":{"line":679,"column":6},"end":{"line":679,"column":21}},{"start":{},"end":{}}],"line":679},"81":{"loc":{"start":{"line":681,"column":6},"end":{"line":681,"column":49}},"type":"if","locations":[{"start":{"line":681,"column":6},"end":{"line":681,"column":49}},{"start":{},"end":{}}],"line":681},"82":{"loc":{"start":{"line":681,"column":10},"end":{"line":681,"column":40}},"type":"binary-expr","locations":[{"start":{"line":681,"column":10},"end":{"line":681,"column":25}},{"start":{"line":681,"column":29},"end":{"line":681,"column":40}}],"line":681},"83":{"loc":{"start":{"line":701,"column":8},"end":{"line":701,"column":41}},"type":"if","locations":[{"start":{"line":701,"column":8},"end":{"line":701,"column":41}},{"start":{},"end":{}}],"line":701},"84":{"loc":{"start":{"line":701,"column":12},"end":{"line":701,"column":32}},"type":"binary-expr","locations":[{"start":{"line":701,"column":12},"end":{"line":701,"column":18}},{"start":{"line":701,"column":22},"end":{"line":701,"column":32}}],"line":701},"85":{"loc":{"start":{"line":703,"column":8},"end":{"line":708,"column":9}},"type":"if","locations":[{"start":{"line":703,"column":8},"end":{"line":708,"column":9}},{"start":{"line":706,"column":15},"end":{"line":708,"column":9}}],"line":703},"86":{"loc":{"start":{"line":721,"column":13},"end":{"line":721,"column":42}},"type":"cond-expr","locations":[{"start":{"line":721,"column":31},"end":{"line":721,"column":37}},{"start":{"line":721,"column":40},"end":{"line":721,"column":42}}],"line":721},"87":{"loc":{"start":{"line":730,"column":6},"end":{"line":739,"column":7}},"type":"switch","locations":[{"start":{"line":731,"column":8},"end":{"line":732,"column":21}},{"start":{"line":733,"column":8},"end":{"line":734,"column":21}},{"start":{"line":735,"column":8},"end":{"line":736,"column":21}},{"start":{"line":737,"column":8},"end":{"line":738,"column":21}}],"line":730},"88":{"loc":{"start":{"line":748,"column":6},"end":{"line":757,"column":7}},"type":"switch","locations":[{"start":{"line":749,"column":8},"end":{"line":750,"column":21}},{"start":{"line":751,"column":8},"end":{"line":752,"column":21}},{"start":{"line":753,"column":8},"end":{"line":754,"column":21}},{"start":{"line":755,"column":8},"end":{"line":756,"column":21}}],"line":748},"89":{"loc":{"start":{"line":766,"column":6},"end":{"line":766,"column":67}},"type":"if","locations":[{"start":{"line":766,"column":6},"end":{"line":766,"column":67}},{"start":{},"end":{}}],"line":766},"90":{"loc":{"start":{"line":766,"column":10},"end":{"line":766,"column":58}},"type":"binary-expr","locations":[{"start":{"line":766,"column":10},"end":{"line":766,"column":16}},{"start":{"line":766,"column":20},"end":{"line":766,"column":34}},{"start":{"line":766,"column":38},"end":{"line":766,"column":58}}],"line":766},"91":{"loc":{"start":{"line":767,"column":6},"end":{"line":767,"column":67}},"type":"if","locations":[{"start":{"line":767,"column":6},"end":{"line":767,"column":67}},{"start":{},"end":{}}],"line":767},"92":{"loc":{"start":{"line":767,"column":10},"end":{"line":767,"column":58}},"type":"binary-expr","locations":[{"start":{"line":767,"column":10},"end":{"line":767,"column":35}},{"start":{"line":767,"column":39},"end":{"line":767,"column":58}}],"line":767},"93":{"loc":{"start":{"line":771,"column":6},"end":{"line":771,"column":30}},"type":"if","locations":[{"start":{"line":771,"column":6},"end":{"line":771,"column":30}},{"start":{},"end":{}}],"line":771},"94":{"loc":{"start":{"line":777,"column":6},"end":{"line":779,"column":7}},"type":"if","locations":[{"start":{"line":777,"column":6},"end":{"line":779,"column":7}},{"start":{},"end":{}}],"line":777},"95":{"loc":{"start":{"line":777,"column":10},"end":{"line":777,"column":66}},"type":"binary-expr","locations":[{"start":{"line":777,"column":10},"end":{"line":777,"column":24}},{"start":{"line":777,"column":28},"end":{"line":777,"column":66}}],"line":777},"96":{"loc":{"start":{"line":783,"column":6},"end":{"line":785,"column":7}},"type":"if","locations":[{"start":{"line":783,"column":6},"end":{"line":785,"column":7}},{"start":{},"end":{}}],"line":783},"97":{"loc":{"start":{"line":798,"column":8},"end":{"line":798,"column":27}},"type":"if","locations":[{"start":{"line":798,"column":8},"end":{"line":798,"column":27}},{"start":{},"end":{}}],"line":798},"98":{"loc":{"start":{"line":800,"column":8},"end":{"line":804,"column":9}},"type":"if","locations":[{"start":{"line":800,"column":8},"end":{"line":804,"column":9}},{"start":{"line":802,"column":15},"end":{"line":804,"column":9}}],"line":800},"99":{"loc":{"start":{"line":815,"column":6},"end":{"line":815,"column":32}},"type":"if","locations":[{"start":{"line":815,"column":6},"end":{"line":815,"column":32}},{"start":{},"end":{}}],"line":815},"100":{"loc":{"start":{"line":819,"column":6},"end":{"line":819,"column":39}},"type":"if","locations":[{"start":{"line":819,"column":6},"end":{"line":819,"column":39}},{"start":{},"end":{}}],"line":819},"101":{"loc":{"start":{"line":830,"column":6},"end":{"line":830,"column":51}},"type":"if","locations":[{"start":{"line":830,"column":6},"end":{"line":830,"column":51}},{"start":{},"end":{}}],"line":830},"102":{"loc":{"start":{"line":830,"column":10},"end":{"line":830,"column":42}},"type":"binary-expr","locations":[{"start":{"line":830,"column":10},"end":{"line":830,"column":25}},{"start":{"line":830,"column":29},"end":{"line":830,"column":42}}],"line":830},"103":{"loc":{"start":{"line":833,"column":6},"end":{"line":835,"column":7}},"type":"if","locations":[{"start":{"line":833,"column":6},"end":{"line":835,"column":7}},{"start":{},"end":{}}],"line":833},"104":{"loc":{"start":{"line":849,"column":6},"end":{"line":849,"column":51}},"type":"if","locations":[{"start":{"line":849,"column":6},"end":{"line":849,"column":51}},{"start":{},"end":{}}],"line":849},"105":{"loc":{"start":{"line":849,"column":10},"end":{"line":849,"column":42}},"type":"binary-expr","locations":[{"start":{"line":849,"column":10},"end":{"line":849,"column":25}},{"start":{"line":849,"column":29},"end":{"line":849,"column":42}}],"line":849},"106":{"loc":{"start":{"line":862,"column":6},"end":{"line":869,"column":7}},"type":"if","locations":[{"start":{"line":862,"column":6},"end":{"line":869,"column":7}},{"start":{},"end":{}}],"line":862},"107":{"loc":{"start":{"line":863,"column":8},"end":{"line":866,"column":52}},"type":"binary-expr","locations":[{"start":{"line":863,"column":8},"end":{"line":863,"column":20}},{"start":{"line":864,"column":8},"end":{"line":864,"column":30}},{"start":{"line":865,"column":9},"end":{"line":865,"column":53}},{"start":{"line":866,"column":10},"end":{"line":866,"column":51}}],"line":863},"108":{"loc":{"start":{"line":884,"column":29},"end":{"line":884,"column":41}},"type":"default-arg","locations":[{"start":{"line":884,"column":36},"end":{"line":884,"column":41}}],"line":884},"109":{"loc":{"start":{"line":886,"column":6},"end":{"line":886,"column":25}},"type":"if","locations":[{"start":{"line":886,"column":6},"end":{"line":886,"column":25}},{"start":{},"end":{}}],"line":886},"110":{"loc":{"start":{"line":889,"column":6},"end":{"line":895,"column":7}},"type":"if","locations":[{"start":{"line":889,"column":6},"end":{"line":895,"column":7}},{"start":{},"end":{}}],"line":889},"111":{"loc":{"start":{"line":889,"column":10},"end":{"line":889,"column":57}},"type":"binary-expr","locations":[{"start":{"line":889,"column":10},"end":{"line":889,"column":15}},{"start":{"line":889,"column":19},"end":{"line":889,"column":57}}],"line":889},"112":{"loc":{"start":{"line":891,"column":8},"end":{"line":894,"column":9}},"type":"if","locations":[{"start":{"line":891,"column":8},"end":{"line":894,"column":9}},{"start":{},"end":{}}],"line":891},"113":{"loc":{"start":{"line":891,"column":12},"end":{"line":891,"column":73}},"type":"binary-expr","locations":[{"start":{"line":891,"column":12},"end":{"line":891,"column":38}},{"start":{"line":891,"column":42},"end":{"line":891,"column":73}}],"line":891},"114":{"loc":{"start":{"line":898,"column":6},"end":{"line":902,"column":7}},"type":"if","locations":[{"start":{"line":898,"column":6},"end":{"line":902,"column":7}},{"start":{"line":900,"column":13},"end":{"line":902,"column":7}}],"line":898},"115":{"loc":{"start":{"line":909,"column":6},"end":{"line":920,"column":7}},"type":"if","locations":[{"start":{"line":909,"column":6},"end":{"line":920,"column":7}},{"start":{},"end":{}}],"line":909},"116":{"loc":{"start":{"line":923,"column":6},"end":{"line":928,"column":7}},"type":"if","locations":[{"start":{"line":923,"column":6},"end":{"line":928,"column":7}},{"start":{},"end":{}}],"line":923},"117":{"loc":{"start":{"line":925,"column":8},"end":{"line":927,"column":9}},"type":"if","locations":[{"start":{"line":925,"column":8},"end":{"line":927,"column":9}},{"start":{},"end":{}}],"line":925},"118":{"loc":{"start":{"line":925,"column":12},"end":{"line":925,"column":62}},"type":"binary-expr","locations":[{"start":{"line":925,"column":12},"end":{"line":925,"column":21}},{"start":{"line":925,"column":25},"end":{"line":925,"column":62}}],"line":925},"119":{"loc":{"start":{"line":931,"column":6},"end":{"line":933,"column":7}},"type":"if","locations":[{"start":{"line":931,"column":6},"end":{"line":933,"column":7}},{"start":{},"end":{}}],"line":931},"120":{"loc":{"start":{"line":940,"column":27},"end":{"line":940,"column":39}},"type":"default-arg","locations":[{"start":{"line":940,"column":34},"end":{"line":940,"column":39}}],"line":940},"121":{"loc":{"start":{"line":942,"column":6},"end":{"line":942,"column":25}},"type":"if","locations":[{"start":{"line":942,"column":6},"end":{"line":942,"column":25}},{"start":{},"end":{}}],"line":942},"122":{"loc":{"start":{"line":952,"column":6},"end":{"line":967,"column":7}},"type":"if","locations":[{"start":{"line":952,"column":6},"end":{"line":967,"column":7}},{"start":{},"end":{}}],"line":952},"123":{"loc":{"start":{"line":964,"column":8},"end":{"line":966,"column":9}},"type":"if","locations":[{"start":{"line":964,"column":8},"end":{"line":966,"column":9}},{"start":{},"end":{}}],"line":964},"124":{"loc":{"start":{"line":964,"column":12},"end":{"line":964,"column":52}},"type":"binary-expr","locations":[{"start":{"line":964,"column":12},"end":{"line":964,"column":17}},{"start":{"line":964,"column":21},"end":{"line":964,"column":52}}],"line":964},"125":{"loc":{"start":{"line":970,"column":6},"end":{"line":972,"column":7}},"type":"if","locations":[{"start":{"line":970,"column":6},"end":{"line":972,"column":7}},{"start":{},"end":{}}],"line":970},"126":{"loc":{"start":{"line":984,"column":8},"end":{"line":984,"column":27}},"type":"if","locations":[{"start":{"line":984,"column":8},"end":{"line":984,"column":27}},{"start":{},"end":{}}],"line":984},"127":{"loc":{"start":{"line":985,"column":8},"end":{"line":987,"column":9}},"type":"if","locations":[{"start":{"line":985,"column":8},"end":{"line":987,"column":9}},{"start":{},"end":{}}],"line":985},"128":{"loc":{"start":{"line":997,"column":20},"end":{"line":997,"column":36}},"type":"default-arg","locations":[{"start":{"line":997,"column":27},"end":{"line":997,"column":36}}],"line":997},"129":{"loc":{"start":{"line":999,"column":8},"end":{"line":1002,"column":9}},"type":"if","locations":[{"start":{"line":999,"column":8},"end":{"line":1002,"column":9}},{"start":{},"end":{}}],"line":999},"130":{"loc":{"start":{"line":999,"column":12},"end":{"line":999,"column":83}},"type":"binary-expr","locations":[{"start":{"line":999,"column":12},"end":{"line":999,"column":41}},{"start":{"line":999,"column":45},"end":{"line":999,"column":83}}],"line":999},"131":{"loc":{"start":{"line":1008,"column":8},"end":{"line":1010,"column":9}},"type":"if","locations":[{"start":{"line":1008,"column":8},"end":{"line":1010,"column":9}},{"start":{},"end":{}}],"line":1008},"132":{"loc":{"start":{"line":1008,"column":12},"end":{"line":1008,"column":79}},"type":"binary-expr","locations":[{"start":{"line":1008,"column":12},"end":{"line":1008,"column":41}},{"start":{"line":1008,"column":45},"end":{"line":1008,"column":79}}],"line":1008},"133":{"loc":{"start":{"line":1022,"column":6},"end":{"line":1022,"column":25}},"type":"if","locations":[{"start":{"line":1022,"column":6},"end":{"line":1022,"column":25}},{"start":{},"end":{}}],"line":1022},"134":{"loc":{"start":{"line":1024,"column":6},"end":{"line":1028,"column":7}},"type":"if","locations":[{"start":{"line":1024,"column":6},"end":{"line":1028,"column":7}},{"start":{"line":1026,"column":13},"end":{"line":1028,"column":7}}],"line":1024},"135":{"loc":{"start":{"line":1035,"column":6},"end":{"line":1038,"column":7}},"type":"if","locations":[{"start":{"line":1035,"column":6},"end":{"line":1038,"column":7}},{"start":{},"end":{}}],"line":1035},"136":{"loc":{"start":{"line":1052,"column":6},"end":{"line":1052,"column":89}},"type":"if","locations":[{"start":{"line":1052,"column":6},"end":{"line":1052,"column":89}},{"start":{},"end":{}}],"line":1052},"137":{"loc":{"start":{"line":1052,"column":10},"end":{"line":1052,"column":80}},"type":"binary-expr","locations":[{"start":{"line":1052,"column":10},"end":{"line":1052,"column":39}},{"start":{"line":1052,"column":43},"end":{"line":1052,"column":80}}],"line":1052},"138":{"loc":{"start":{"line":1055,"column":6},"end":{"line":1055,"column":56}},"type":"if","locations":[{"start":{"line":1055,"column":6},"end":{"line":1055,"column":56}},{"start":{},"end":{}}],"line":1055},"139":{"loc":{"start":{"line":1055,"column":10},"end":{"line":1055,"column":47}},"type":"binary-expr","locations":[{"start":{"line":1055,"column":10},"end":{"line":1055,"column":33}},{"start":{"line":1055,"column":37},"end":{"line":1055,"column":47}}],"line":1055},"140":{"loc":{"start":{"line":1061,"column":6},"end":{"line":1065,"column":7}},"type":"if","locations":[{"start":{"line":1061,"column":6},"end":{"line":1065,"column":7}},{"start":{"line":1063,"column":13},"end":{"line":1065,"column":7}}],"line":1061},"141":{"loc":{"start":{"line":1067,"column":6},"end":{"line":1071,"column":7}},"type":"if","locations":[{"start":{"line":1067,"column":6},"end":{"line":1071,"column":7}},{"start":{"line":1069,"column":13},"end":{"line":1071,"column":7}}],"line":1067},"142":{"loc":{"start":{"line":1073,"column":6},"end":{"line":1077,"column":7}},"type":"if","locations":[{"start":{"line":1073,"column":6},"end":{"line":1077,"column":7}},{"start":{"line":1075,"column":13},"end":{"line":1077,"column":7}}],"line":1073},"143":{"loc":{"start":{"line":1086,"column":6},"end":{"line":1086,"column":25}},"type":"if","locations":[{"start":{"line":1086,"column":6},"end":{"line":1086,"column":25}},{"start":{},"end":{}}],"line":1086},"144":{"loc":{"start":{"line":1089,"column":6},"end":{"line":1089,"column":72}},"type":"if","locations":[{"start":{"line":1089,"column":6},"end":{"line":1089,"column":72}},{"start":{},"end":{}}],"line":1089},"145":{"loc":{"start":{"line":1089,"column":10},"end":{"line":1089,"column":63}},"type":"binary-expr","locations":[{"start":{"line":1089,"column":10},"end":{"line":1089,"column":25}},{"start":{"line":1089,"column":29},"end":{"line":1089,"column":63}}],"line":1089},"146":{"loc":{"start":{"line":1092,"column":6},"end":{"line":1092,"column":75}},"type":"if","locations":[{"start":{"line":1092,"column":6},"end":{"line":1092,"column":75}},{"start":{},"end":{}}],"line":1092},"147":{"loc":{"start":{"line":1092,"column":10},"end":{"line":1092,"column":66}},"type":"binary-expr","locations":[{"start":{"line":1092,"column":10},"end":{"line":1092,"column":25}},{"start":{"line":1092,"column":29},"end":{"line":1092,"column":66}}],"line":1092},"148":{"loc":{"start":{"line":1103,"column":6},"end":{"line":1103,"column":25}},"type":"if","locations":[{"start":{"line":1103,"column":6},"end":{"line":1103,"column":25}},{"start":{},"end":{}}],"line":1103},"149":{"loc":{"start":{"line":1105,"column":6},"end":{"line":1105,"column":72}},"type":"if","locations":[{"start":{"line":1105,"column":6},"end":{"line":1105,"column":72}},{"start":{},"end":{}}],"line":1105},"150":{"loc":{"start":{"line":1105,"column":10},"end":{"line":1105,"column":63}},"type":"binary-expr","locations":[{"start":{"line":1105,"column":10},"end":{"line":1105,"column":25}},{"start":{"line":1105,"column":29},"end":{"line":1105,"column":63}}],"line":1105},"151":{"loc":{"start":{"line":1107,"column":30},"end":{"line":1107,"column":85}},"type":"binary-expr","locations":[{"start":{"line":1107,"column":30},"end":{"line":1107,"column":43}},{"start":{"line":1107,"column":47},"end":{"line":1107,"column":85}}],"line":1107},"152":{"loc":{"start":{"line":1108,"column":6},"end":{"line":1108,"column":35}},"type":"if","locations":[{"start":{"line":1108,"column":6},"end":{"line":1108,"column":35}},{"start":{},"end":{}}],"line":1108},"153":{"loc":{"start":{"line":1110,"column":6},"end":{"line":1112,"column":7}},"type":"if","locations":[{"start":{"line":1110,"column":6},"end":{"line":1112,"column":7}},{"start":{},"end":{}}],"line":1110},"154":{"loc":{"start":{"line":1110,"column":10},"end":{"line":1110,"column":66}},"type":"binary-expr","locations":[{"start":{"line":1110,"column":10},"end":{"line":1110,"column":25}},{"start":{"line":1110,"column":29},"end":{"line":1110,"column":66}}],"line":1110},"155":{"loc":{"start":{"line":1120,"column":6},"end":{"line":1120,"column":21}},"type":"if","locations":[{"start":{"line":1120,"column":6},"end":{"line":1120,"column":21}},{"start":{},"end":{}}],"line":1120},"156":{"loc":{"start":{"line":1123,"column":6},"end":{"line":1123,"column":62}},"type":"if","locations":[{"start":{"line":1123,"column":6},"end":{"line":1123,"column":62}},{"start":{},"end":{}}],"line":1123},"157":{"loc":{"start":{"line":1123,"column":10},"end":{"line":1123,"column":53}},"type":"binary-expr","locations":[{"start":{"line":1123,"column":10},"end":{"line":1123,"column":29}},{"start":{"line":1123,"column":33},"end":{"line":1123,"column":53}}],"line":1123},"158":{"loc":{"start":{"line":1126,"column":24},"end":{"line":1126,"column":83}},"type":"cond-expr","locations":[{"start":{"line":1126,"column":53},"end":{"line":1126,"column":76}},{"start":{"line":1126,"column":79},"end":{"line":1126,"column":83}}],"line":1126},"159":{"loc":{"start":{"line":1126,"column":24},"end":{"line":1126,"column":50}},"type":"binary-expr","locations":[{"start":{"line":1126,"column":24},"end":{"line":1126,"column":31}},{"start":{"line":1126,"column":35},"end":{"line":1126,"column":50}}],"line":1126},"160":{"loc":{"start":{"line":1127,"column":6},"end":{"line":1127,"column":29}},"type":"if","locations":[{"start":{"line":1127,"column":6},"end":{"line":1127,"column":29}},{"start":{},"end":{}}],"line":1127},"161":{"loc":{"start":{"line":1130,"column":6},"end":{"line":1130,"column":25}},"type":"if","locations":[{"start":{"line":1130,"column":6},"end":{"line":1130,"column":25}},{"start":{},"end":{}}],"line":1130},"162":{"loc":{"start":{"line":1131,"column":6},"end":{"line":1131,"column":75}},"type":"if","locations":[{"start":{"line":1131,"column":6},"end":{"line":1131,"column":75}},{"start":{},"end":{}}],"line":1131},"163":{"loc":{"start":{"line":1131,"column":10},"end":{"line":1131,"column":66}},"type":"binary-expr","locations":[{"start":{"line":1131,"column":10},"end":{"line":1131,"column":25}},{"start":{"line":1131,"column":29},"end":{"line":1131,"column":66}}],"line":1131},"164":{"loc":{"start":{"line":1133,"column":19},"end":{"line":1133,"column":39}},"type":"cond-expr","locations":[{"start":{"line":1133,"column":32},"end":{"line":1133,"column":34}},{"start":{"line":1133,"column":37},"end":{"line":1133,"column":39}}],"line":1133},"165":{"loc":{"start":{"line":1134,"column":20},"end":{"line":1134,"column":54}},"type":"cond-expr","locations":[{"start":{"line":1134,"column":42},"end":{"line":1134,"column":47}},{"start":{"line":1134,"column":50},"end":{"line":1134,"column":54}}],"line":1134},"166":{"loc":{"start":{"line":1143,"column":6},"end":{"line":1145,"column":7}},"type":"if","locations":[{"start":{"line":1143,"column":6},"end":{"line":1145,"column":7}},{"start":{},"end":{}}],"line":1143},"167":{"loc":{"start":{"line":1143,"column":10},"end":{"line":1143,"column":49}},"type":"binary-expr","locations":[{"start":{"line":1143,"column":10},"end":{"line":1143,"column":28}},{"start":{"line":1143,"column":32},"end":{"line":1143,"column":49}}],"line":1143},"168":{"loc":{"start":{"line":1154,"column":6},"end":{"line":1154,"column":70}},"type":"if","locations":[{"start":{"line":1154,"column":6},"end":{"line":1154,"column":70}},{"start":{},"end":{}}],"line":1154},"169":{"loc":{"start":{"line":1154,"column":10},"end":{"line":1154,"column":61}},"type":"binary-expr","locations":[{"start":{"line":1154,"column":10},"end":{"line":1154,"column":11}},{"start":{"line":1154,"column":15},"end":{"line":1154,"column":43}},{"start":{"line":1154,"column":47},"end":{"line":1154,"column":61}}],"line":1154},"170":{"loc":{"start":{"line":1157,"column":24},"end":{"line":1157,"column":83}},"type":"cond-expr","locations":[{"start":{"line":1157,"column":53},"end":{"line":1157,"column":76}},{"start":{"line":1157,"column":79},"end":{"line":1157,"column":83}}],"line":1157},"171":{"loc":{"start":{"line":1157,"column":24},"end":{"line":1157,"column":50}},"type":"binary-expr","locations":[{"start":{"line":1157,"column":24},"end":{"line":1157,"column":31}},{"start":{"line":1157,"column":35},"end":{"line":1157,"column":50}}],"line":1157},"172":{"loc":{"start":{"line":1158,"column":6},"end":{"line":1158,"column":29}},"type":"if","locations":[{"start":{"line":1158,"column":6},"end":{"line":1158,"column":29}},{"start":{},"end":{}}],"line":1158},"173":{"loc":{"start":{"line":1161,"column":6},"end":{"line":1161,"column":25}},"type":"if","locations":[{"start":{"line":1161,"column":6},"end":{"line":1161,"column":25}},{"start":{},"end":{}}],"line":1161},"174":{"loc":{"start":{"line":1163,"column":6},"end":{"line":1163,"column":56}},"type":"if","locations":[{"start":{"line":1163,"column":6},"end":{"line":1163,"column":56}},{"start":{},"end":{}}],"line":1163},"175":{"loc":{"start":{"line":1168,"column":6},"end":{"line":1170,"column":7}},"type":"if","locations":[{"start":{"line":1168,"column":6},"end":{"line":1170,"column":7}},{"start":{},"end":{}}],"line":1168},"176":{"loc":{"start":{"line":1176,"column":6},"end":{"line":1178,"column":7}},"type":"if","locations":[{"start":{"line":1176,"column":6},"end":{"line":1178,"column":7}},{"start":{},"end":{}}],"line":1176},"177":{"loc":{"start":{"line":1176,"column":10},"end":{"line":1176,"column":62}},"type":"binary-expr","locations":[{"start":{"line":1176,"column":10},"end":{"line":1176,"column":29}},{"start":{"line":1176,"column":33},"end":{"line":1176,"column":62}}],"line":1176},"178":{"loc":{"start":{"line":1195,"column":6},"end":{"line":1195,"column":83}},"type":"if","locations":[{"start":{"line":1195,"column":6},"end":{"line":1195,"column":83}},{"start":{},"end":{}}],"line":1195},"179":{"loc":{"start":{"line":1195,"column":10},"end":{"line":1195,"column":74}},"type":"binary-expr","locations":[{"start":{"line":1195,"column":10},"end":{"line":1195,"column":26}},{"start":{"line":1195,"column":30},"end":{"line":1195,"column":48}},{"start":{"line":1195,"column":52},"end":{"line":1195,"column":74}}],"line":1195},"180":{"loc":{"start":{"line":1199,"column":6},"end":{"line":1199,"column":29}},"type":"if","locations":[{"start":{"line":1199,"column":6},"end":{"line":1199,"column":29}},{"start":{},"end":{}}],"line":1199},"181":{"loc":{"start":{"line":1202,"column":8},"end":{"line":1202,"column":85}},"type":"if","locations":[{"start":{"line":1202,"column":8},"end":{"line":1202,"column":85}},{"start":{},"end":{}}],"line":1202},"182":{"loc":{"start":{"line":1202,"column":12},"end":{"line":1202,"column":76}},"type":"binary-expr","locations":[{"start":{"line":1202,"column":12},"end":{"line":1202,"column":28}},{"start":{"line":1202,"column":32},"end":{"line":1202,"column":50}},{"start":{"line":1202,"column":54},"end":{"line":1202,"column":76}}],"line":1202},"183":{"loc":{"start":{"line":1229,"column":6},"end":{"line":1229,"column":35}},"type":"if","locations":[{"start":{"line":1229,"column":6},"end":{"line":1229,"column":35}},{"start":{},"end":{}}],"line":1229},"184":{"loc":{"start":{"line":1232,"column":6},"end":{"line":1252,"column":7}},"type":"if","locations":[{"start":{"line":1232,"column":6},"end":{"line":1252,"column":7}},{"start":{},"end":{}}],"line":1232},"185":{"loc":{"start":{"line":1232,"column":10},"end":{"line":1232,"column":66}},"type":"binary-expr","locations":[{"start":{"line":1232,"column":10},"end":{"line":1232,"column":20}},{"start":{"line":1232,"column":24},"end":{"line":1232,"column":41}},{"start":{"line":1232,"column":45},"end":{"line":1232,"column":66}}],"line":1232},"186":{"loc":{"start":{"line":1264,"column":6},"end":{"line":1266,"column":7}},"type":"if","locations":[{"start":{"line":1264,"column":6},"end":{"line":1266,"column":7}},{"start":{},"end":{}}],"line":1264},"187":{"loc":{"start":{"line":1264,"column":10},"end":{"line":1264,"column":62}},"type":"binary-expr","locations":[{"start":{"line":1264,"column":10},"end":{"line":1264,"column":29}},{"start":{"line":1264,"column":33},"end":{"line":1264,"column":62}}],"line":1264},"188":{"loc":{"start":{"line":1268,"column":6},"end":{"line":1270,"column":7}},"type":"if","locations":[{"start":{"line":1268,"column":6},"end":{"line":1270,"column":7}},{"start":{},"end":{}}],"line":1268},"189":{"loc":{"start":{"line":1273,"column":6},"end":{"line":1275,"column":7}},"type":"if","locations":[{"start":{"line":1273,"column":6},"end":{"line":1275,"column":7}},{"start":{},"end":{}}],"line":1273},"190":{"loc":{"start":{"line":1273,"column":10},"end":{"line":1273,"column":49}},"type":"binary-expr","locations":[{"start":{"line":1273,"column":10},"end":{"line":1273,"column":28}},{"start":{"line":1273,"column":32},"end":{"line":1273,"column":49}}],"line":1273},"191":{"loc":{"start":{"line":1299,"column":6},"end":{"line":1306,"column":7}},"type":"if","locations":[{"start":{"line":1299,"column":6},"end":{"line":1306,"column":7}},{"start":{},"end":{}}],"line":1299},"192":{"loc":{"start":{"line":1299,"column":10},"end":{"line":1299,"column":50}},"type":"binary-expr","locations":[{"start":{"line":1299,"column":10},"end":{"line":1299,"column":28}},{"start":{"line":1299,"column":32},"end":{"line":1299,"column":50}}],"line":1299},"193":{"loc":{"start":{"line":1302,"column":8},"end":{"line":1305,"column":9}},"type":"if","locations":[{"start":{"line":1302,"column":8},"end":{"line":1305,"column":9}},{"start":{},"end":{}}],"line":1302},"194":{"loc":{"start":{"line":1318,"column":8},"end":{"line":1318,"column":51}},"type":"if","locations":[{"start":{"line":1318,"column":8},"end":{"line":1318,"column":51}},{"start":{},"end":{}}],"line":1318},"195":{"loc":{"start":{"line":1330,"column":6},"end":{"line":1337,"column":7}},"type":"if","locations":[{"start":{"line":1330,"column":6},"end":{"line":1337,"column":7}},{"start":{},"end":{}}],"line":1330},"196":{"loc":{"start":{"line":1331,"column":21},"end":{"line":1333,"column":16}},"type":"cond-expr","locations":[{"start":{"line":1332,"column":12},"end":{"line":1332,"column":50}},{"start":{"line":1333,"column":12},"end":{"line":1333,"column":16}}],"line":1331},"197":{"loc":{"start":{"line":1334,"column":8},"end":{"line":1336,"column":9}},"type":"if","locations":[{"start":{"line":1334,"column":8},"end":{"line":1336,"column":9}},{"start":{},"end":{}}],"line":1334},"198":{"loc":{"start":{"line":1334,"column":12},"end":{"line":1334,"column":68}},"type":"binary-expr","locations":[{"start":{"line":1334,"column":12},"end":{"line":1334,"column":16}},{"start":{"line":1334,"column":20},"end":{"line":1334,"column":50}},{"start":{"line":1334,"column":54},"end":{"line":1334,"column":68}}],"line":1334},"199":{"loc":{"start":{"line":1338,"column":6},"end":{"line":1340,"column":7}},"type":"if","locations":[{"start":{"line":1338,"column":6},"end":{"line":1340,"column":7}},{"start":{},"end":{}}],"line":1338},"200":{"loc":{"start":{"line":1338,"column":10},"end":{"line":1338,"column":80}},"type":"binary-expr","locations":[{"start":{"line":1338,"column":10},"end":{"line":1338,"column":39}},{"start":{"line":1338,"column":43},"end":{"line":1338,"column":80}}],"line":1338},"201":{"loc":{"start":{"line":1353,"column":13},"end":{"line":1353,"column":43}},"type":"cond-expr","locations":[{"start":{"line":1353,"column":31},"end":{"line":1353,"column":37}},{"start":{"line":1353,"column":40},"end":{"line":1353,"column":43}}],"line":1353},"202":{"loc":{"start":{"line":1365,"column":8},"end":{"line":1367,"column":10}},"type":"binary-expr","locations":[{"start":{"line":1365,"column":9},"end":{"line":1365,"column":19}},{"start":{"line":1366,"column":10},"end":{"line":1366,"column":81}},{"start":{"line":1367,"column":8},"end":{"line":1367,"column":10}}],"line":1365},"203":{"loc":{"start":{"line":1369,"column":6},"end":{"line":1369,"column":41}},"type":"if","locations":[{"start":{"line":1369,"column":6},"end":{"line":1369,"column":41}},{"start":{},"end":{}}],"line":1369},"204":{"loc":{"start":{"line":1371,"column":6},"end":{"line":1381,"column":7}},"type":"if","locations":[{"start":{"line":1371,"column":6},"end":{"line":1381,"column":7}},{"start":{},"end":{}}],"line":1371},"205":{"loc":{"start":{"line":1372,"column":21},"end":{"line":1374,"column":16}},"type":"cond-expr","locations":[{"start":{"line":1373,"column":12},"end":{"line":1373,"column":48}},{"start":{"line":1374,"column":12},"end":{"line":1374,"column":16}}],"line":1372},"206":{"loc":{"start":{"line":1375,"column":8},"end":{"line":1377,"column":9}},"type":"if","locations":[{"start":{"line":1375,"column":8},"end":{"line":1377,"column":9}},{"start":{},"end":{}}],"line":1375},"207":{"loc":{"start":{"line":1375,"column":12},"end":{"line":1375,"column":68}},"type":"binary-expr","locations":[{"start":{"line":1375,"column":12},"end":{"line":1375,"column":16}},{"start":{"line":1375,"column":20},"end":{"line":1375,"column":50}},{"start":{"line":1375,"column":54},"end":{"line":1375,"column":68}}],"line":1375},"208":{"loc":{"start":{"line":1378,"column":8},"end":{"line":1380,"column":9}},"type":"if","locations":[{"start":{"line":1378,"column":8},"end":{"line":1380,"column":9}},{"start":{},"end":{}}],"line":1378},"209":{"loc":{"start":{"line":1378,"column":12},"end":{"line":1378,"column":88}},"type":"binary-expr","locations":[{"start":{"line":1378,"column":12},"end":{"line":1378,"column":56}},{"start":{"line":1378,"column":60},"end":{"line":1378,"column":88}}],"line":1378},"210":{"loc":{"start":{"line":1392,"column":6},"end":{"line":1392,"column":59}},"type":"if","locations":[{"start":{"line":1392,"column":6},"end":{"line":1392,"column":59}},{"start":{},"end":{}}],"line":1392},"211":{"loc":{"start":{"line":1392,"column":10},"end":{"line":1392,"column":45}},"type":"binary-expr","locations":[{"start":{"line":1392,"column":10},"end":{"line":1392,"column":16}},{"start":{"line":1392,"column":20},"end":{"line":1392,"column":45}}],"line":1392},"212":{"loc":{"start":{"line":1394,"column":6},"end":{"line":1394,"column":47}},"type":"if","locations":[{"start":{"line":1394,"column":6},"end":{"line":1394,"column":47}},{"start":{},"end":{}}],"line":1394},"213":{"loc":{"start":{"line":1396,"column":13},"end":{"line":1396,"column":46}},"type":"cond-expr","locations":[{"start":{"line":1396,"column":36},"end":{"line":1396,"column":39}},{"start":{"line":1396,"column":42},"end":{"line":1396,"column":46}}],"line":1396},"214":{"loc":{"start":{"line":1404,"column":2},"end":{"line":1406,"column":3}},"type":"if","locations":[{"start":{"line":1404,"column":2},"end":{"line":1406,"column":3}},{"start":{},"end":{}}],"line":1404},"215":{"loc":{"start":{"line":1404,"column":6},"end":{"line":1404,"column":53}},"type":"binary-expr","locations":[{"start":{"line":1404,"column":6},"end":{"line":1404,"column":35}},{"start":{"line":1404,"column":39},"end":{"line":1404,"column":53}}],"line":1404},"216":{"loc":{"start":{"line":1407,"column":3},"end":{"line":1407,"column":54}},"type":"cond-expr","locations":[{"start":{"line":1407,"column":35},"end":{"line":1407,"column":41}},{"start":{"line":1407,"column":44},"end":{"line":1407,"column":54}}],"line":1407}},"s":{"0":12,"1":12,"2":12,"3":12,"4":12,"5":12,"6":97,"7":97,"8":97,"9":97,"10":97,"11":97,"12":97,"13":97,"14":97,"15":97,"16":97,"17":97,"18":97,"19":97,"20":97,"21":97,"22":97,"23":97,"24":97,"25":97,"26":97,"27":97,"28":97,"29":97,"30":97,"31":40,"32":97,"33":9,"34":97,"35":97,"36":97,"37":291,"38":97,"39":97,"40":291,"41":97,"42":291,"43":291,"44":291,"45":291,"46":97,"47":291,"48":291,"49":97,"50":97,"51":97,"52":96,"53":96,"54":97,"55":97,"56":85,"57":97,"58":97,"59":97,"60":97,"61":85,"62":97,"63":97,"64":97,"65":97,"66":97,"67":97,"68":97,"69":86,"70":11,"71":11,"72":11,"73":11,"74":0,"75":0,"76":0,"77":194,"78":194,"79":31,"80":2,"81":31,"82":13,"83":13,"84":36,"85":1,"86":1,"87":36,"88":2,"89":1,"90":1,"91":43,"92":43,"93":129,"94":129,"95":129,"96":129,"97":129,"98":129,"99":129,"100":129,"101":43,"102":142,"103":142,"104":568,"105":565,"106":3,"107":3,"108":142,"109":142,"110":142,"111":142,"112":0,"113":142,"114":142,"115":142,"116":0,"117":142,"118":142,"119":142,"120":142,"121":0,"122":142,"123":142,"124":142,"125":3,"126":139,"127":43,"128":0,"129":43,"130":43,"131":43,"132":36,"133":0,"134":36,"135":97,"136":0,"137":97,"138":97,"139":97,"140":96,"141":1,"142":1,"143":1,"144":1,"145":1,"146":98,"147":98,"148":0,"149":0,"150":98,"151":98,"152":98,"153":98,"154":67,"155":67,"156":98,"157":98,"158":0,"159":2,"160":2,"161":0,"162":0,"163":2,"164":2,"165":2,"166":2,"167":1,"168":1,"169":2,"170":2,"171":2,"172":2,"173":2,"174":2,"175":2,"176":2,"177":2,"178":6,"179":6,"180":0,"181":6,"182":6,"183":6,"184":6,"185":2,"186":2,"187":2,"188":1,"189":1,"190":0,"191":1,"192":3,"193":3,"194":3,"195":0,"196":3,"197":3,"198":3,"199":3,"200":3,"201":3,"202":3,"203":3,"204":1,"205":1,"206":2,"207":2,"208":9,"209":9,"210":0,"211":9,"212":9,"213":0,"214":9,"215":1,"216":1,"217":1,"218":1,"219":1,"220":8,"221":8,"222":8,"223":8,"224":1024,"225":389,"226":324,"227":310,"228":1,"229":9,"230":9,"231":9,"232":2,"233":7,"234":7,"235":98,"236":98,"237":294,"238":294,"239":0,"240":294,"241":294,"242":291,"243":291,"244":291,"245":291,"246":291,"247":291,"248":291,"249":291,"250":291,"251":291,"252":291,"253":291,"254":294,"255":294,"256":294,"257":294,"258":294,"259":5,"260":5,"261":5,"262":2,"263":3,"264":3,"265":3,"266":1,"267":2,"268":2,"269":1,"270":1,"271":1,"272":165,"273":165,"274":165,"275":495,"276":495,"277":495,"278":0,"279":495,"280":69,"281":69,"282":426,"283":165,"284":165,"285":165,"286":374,"287":165,"288":105,"289":103,"290":1,"291":59,"292":54,"293":2,"294":2,"295":1,"296":68,"297":5,"298":63,"299":1,"300":62,"301":62,"302":62,"303":1,"304":61,"305":61,"306":61,"307":60,"308":61,"309":61,"310":61,"311":100,"312":300,"313":300,"314":0,"315":300,"316":2,"317":298,"318":315,"319":315,"320":0,"321":315,"322":315,"323":315,"324":0,"325":315,"326":315,"327":315,"328":315,"329":0,"330":315,"331":315,"332":315,"333":315,"334":50,"335":50,"336":50,"337":0,"338":50,"339":50,"340":132,"341":1,"342":131,"343":52,"344":52,"345":2,"346":50,"347":24,"348":24,"349":2,"350":2,"351":48,"352":26,"353":22,"354":48,"355":48,"356":48,"357":48,"358":48,"359":48,"360":48,"361":48,"362":48,"363":48,"364":22,"365":22,"366":22,"367":48,"368":22,"369":19,"370":19,"371":2,"372":17,"373":17,"374":17,"375":17,"376":17,"377":17,"378":17,"379":17,"380":17,"381":17,"382":17,"383":12,"384":17,"385":12,"386":27,"387":27,"388":81,"389":81,"390":0,"391":81,"392":74,"393":27,"394":4,"395":4,"396":4,"397":4,"398":0,"399":0,"400":0,"401":15,"402":15,"403":2,"404":13,"405":3,"406":10,"407":9,"408":0,"409":0,"410":9,"411":106,"412":0,"413":106,"414":106,"415":0,"416":106,"417":106,"418":106,"419":106,"420":6,"421":100,"422":106,"423":4,"424":102,"425":106,"426":3,"427":103,"428":13,"429":13,"430":0,"431":13,"432":1,"433":12,"434":1,"435":11,"436":305,"437":305,"438":0,"439":305,"440":1,"441":304,"442":304,"443":299,"444":5,"445":5,"446":9,"447":0,"448":9,"449":9,"450":0,"451":9,"452":9,"453":9,"454":0,"455":9,"456":9,"457":0,"458":9,"459":0,"460":9,"461":9,"462":9,"463":9,"464":9,"465":9,"466":9,"467":9,"468":17,"469":1,"470":16,"471":16,"472":16,"473":1,"474":15,"475":15,"476":1,"477":14,"478":2,"479":12,"480":12,"481":12,"482":12,"483":12,"484":12,"485":12,"486":12,"487":12,"488":12,"489":12,"490":12,"491":12,"492":12,"493":12,"494":12,"495":50,"496":1,"497":49,"498":49,"499":10,"500":39,"501":30,"502":30,"503":0,"504":30,"505":30,"506":30,"507":0,"508":0,"509":0,"510":13,"511":1,"512":12,"513":9,"514":9,"515":9,"516":9,"517":9,"518":0,"519":0,"520":12,"521":12,"522":12,"523":12,"524":12,"525":12,"526":12,"527":12,"528":12,"529":12,"530":12,"531":12,"532":12,"533":12,"534":52,"535":52,"536":52,"537":52,"538":52,"539":52,"540":52,"541":52,"542":52,"543":52,"544":52,"545":52,"546":52,"547":52,"548":55,"549":55,"550":165,"551":55,"552":110,"553":110,"554":55,"555":54,"556":53,"557":53,"558":1,"559":53,"560":52,"561":1,"562":52,"563":52,"564":52,"565":134,"566":134,"567":134,"568":134,"569":131,"570":3,"571":2,"572":2,"573":1,"574":1,"575":1,"576":1,"577":804,"578":220,"579":584,"580":584,"581":2,"582":582,"583":582,"584":12,"585":12,"586":12},"f":{"0":12,"1":97,"2":40,"3":9,"4":97,"5":291,"6":291,"7":291,"8":291,"9":97,"10":97,"11":194,"12":31,"13":13,"14":36,"15":2,"16":43,"17":129,"18":142,"19":568,"20":43,"21":36,"22":97,"23":1,"24":98,"25":67,"26":2,"27":1,"28":2,"29":2,"30":6,"31":1,"32":3,"33":9,"34":1024,"35":9,"36":98,"37":294,"38":5,"39":3,"40":1,"41":165,"42":495,"43":165,"44":374,"45":59,"46":68,"47":100,"48":300,"49":315,"50":315,"51":50,"52":132,"53":52,"54":19,"55":27,"56":81,"57":4,"58":15,"59":9,"60":106,"61":13,"62":305,"63":9,"64":17,"65":50,"66":30,"67":13,"68":52,"69":55,"70":165,"71":54,"72":52,"73":134,"74":804},"b":{"0":[96,1],"1":[97,0],"2":[97,97],"3":[85,12],"4":[97,0],"5":[97,97],"6":[85,12],"7":[86,11],"8":[97,97],"9":[11,0],"10":[11,0],"11":[2,29],"12":[1,35],"13":[1,1],"14":[129,129,0],"15":[129,0],"16":[129,129,22],"17":[43,0],"18":[565,3],"19":[568,426],"20":[3,0],"21":[142,0],"22":[142,0],"23":[142,142],"24":[0,142],"25":[142,0],"26":[142,142],"27":[0,142],"28":[142,0],"29":[0,142],"30":[0,142],"31":[142,0],"32":[3,139],"33":[0,43],"34":[43,43],"35":[43,42],"36":[0,36],"37":[0,97],"38":[97,97],"39":[97,95],"40":[96,1],"41":[97,1],"42":[98,0],"43":[98,98],"44":[0,98],"45":[98,98],"46":[98,0],"47":[2,0],"48":[2,2],"49":[0,2],"50":[2,2],"51":[2,0],"52":[2,2],"53":[2,2],"54":[2,0],"55":[2,2],"56":[0,6],"57":[1,0],"58":[1,1],"59":[0,1],"60":[0,3],"61":[3,3],"62":[3,0],"63":[1,2],"64":[0,9],"65":[0,9],"66":[1,8],"67":[389,324,310,1],"68":[8,1],"69":[9,8],"70":[8,1],"71":[9,8],"72":[2,7],"73":[0,294],"74":[291,3],"75":[4,1],"76":[5,4],"77":[4,1],"78":[5,4],"79":[2,3],"80":[1,2],"81":[1,1],"82":[2,1],"83":[0,495],"84":[495,495],"85":[69,426],"86":[1,164],"87":[165,105,103,1],"88":[54,2,2,1],"89":[5,63],"90":[68,67,63],"91":[1,62],"92":[63,63],"93":[1,61],"94":[60,1],"95":[61,61],"96":[61,0],"97":[0,300],"98":[2,298],"99":[0,315],"100":[0,315],"101":[0,315],"102":[315,315],"103":[315,0],"104":[0,50],"105":[50,50],"106":[1,131],"107":[132,132,132,131],"108":[11],"109":[2,50],"110":[24,26],"111":[50,24],"112":[2,22],"113":[24,2],"114":[26,22],"115":[48,0],"116":[22,26],"117":[22,0],"118":[22,22],"119":[22,26],"120":[14],"121":[2,17],"122":[17,0],"123":[12,5],"124":[17,12],"125":[12,5],"126":[0,81],"127":[74,7],"128":[0],"129":[4,0],"130":[4,4],"131":[0,0],"132":[0,0],"133":[2,13],"134":[3,10],"135":[0,9],"136":[0,106],"137":[106,106],"138":[0,106],"139":[106,106],"140":[6,100],"141":[4,102],"142":[3,103],"143":[0,13],"144":[1,12],"145":[13,13],"146":[1,11],"147":[12,12],"148":[0,305],"149":[1,304],"150":[305,305],"151":[304,304],"152":[299,5],"153":[5,0],"154":[5,5],"155":[0,9],"156":[0,9],"157":[9,7],"158":[9,0],"159":[9,9],"160":[0,9],"161":[0,9],"162":[0,9],"163":[9,9],"164":[2,7],"165":[2,7],"166":[9,0],"167":[9,9],"168":[1,16],"169":[17,17,17],"170":[15,1],"171":[16,16],"172":[1,15],"173":[1,14],"174":[2,12],"175":[12,0],"176":[12,0],"177":[12,12],"178":[1,49],"179":[50,49,49],"180":[10,39],"181":[0,30],"182":[30,30,30],"183":[1,12],"184":[9,3],"185":[12,9,9],"186":[12,0],"187":[12,12],"188":[12,0],"189":[12,0],"190":[12,12],"191":[52,0],"192":[52,52],"193":[52,0],"194":[55,110],"195":[53,1],"196":[53,0],"197":[1,52],"198":[53,53,53],"199":[52,1],"200":[53,53],"201":[0,52],"202":[134,132,2],"203":[131,3],"204":[2,1],"205":[2,0],"206":[1,1],"207":[2,2,2],"208":[1,0],"209":[1,1],"210":[220,584],"211":[804,584],"212":[2,582],"213":[581,1],"214":[12,0],"215":[12,12],"216":[12,0]},"_coverageSchema":"1a1c01bbd47fc00a2c39e90264f33305004495a9","hash":"8e4eb9c1ed657cf4179139e1a3f7b7332c3121b5"} -,"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,"column":41}},"70":{"start":{"line":201,"column":8},"end":{"line":201,"column":29}},"71":{"start":{"line":201,"column":22},"end":{"line":201,"column":29}},"72":{"start":{"line":202,"column":20},"end":{"line":202,"column":46}},"73":{"start":{"line":203,"column":8},"end":{"line":203,"column":32}},"74":{"start":{"line":215,"column":6},"end":{"line":215,"column":31}},"75":{"start":{"line":215,"column":18},"end":{"line":215,"column":31}},"76":{"start":{"line":216,"column":19},"end":{"line":216,"column":67}},"77":{"start":{"line":217,"column":6},"end":{"line":217,"column":53}},"78":{"start":{"line":217,"column":41},"end":{"line":217,"column":53}},"79":{"start":{"line":218,"column":6},"end":{"line":218,"column":61}},"80":{"start":{"line":218,"column":49},"end":{"line":218,"column":61}},"81":{"start":{"line":219,"column":19},"end":{"line":219,"column":69}},"82":{"start":{"line":221,"column":6},"end":{"line":221,"column":42}},"83":{"start":{"line":230,"column":22},"end":{"line":230,"column":39}},"84":{"start":{"line":231,"column":6},"end":{"line":231,"column":27}},"85":{"start":{"line":231,"column":20},"end":{"line":231,"column":27}},"86":{"start":{"line":233,"column":19},"end":{"line":233,"column":21}},"87":{"start":{"line":234,"column":6},"end":{"line":244,"column":7}},"88":{"start":{"line":235,"column":8},"end":{"line":241,"column":9}},"89":{"start":{"line":235,"column":21},"end":{"line":235,"column":22}},"90":{"start":{"line":236,"column":20},"end":{"line":236,"column":34}},"91":{"start":{"line":237,"column":10},"end":{"line":237,"column":27}},"92":{"start":{"line":237,"column":18},"end":{"line":237,"column":27}},"93":{"start":{"line":238,"column":10},"end":{"line":238,"column":58}},"94":{"start":{"line":238,"column":49},"end":{"line":238,"column":58}},"95":{"start":{"line":239,"column":10},"end":{"line":239,"column":55}},"96":{"start":{"line":239,"column":46},"end":{"line":239,"column":55}},"97":{"start":{"line":240,"column":10},"end":{"line":240,"column":23}},"98":{"start":{"line":247,"column":20},"end":{"line":259,"column":8}},"99":{"start":{"line":248,"column":17},"end":{"line":248,"column":18}},"100":{"start":{"line":249,"column":8},"end":{"line":257,"column":9}},"101":{"start":{"line":250,"column":22},"end":{"line":250,"column":40}},"102":{"start":{"line":251,"column":10},"end":{"line":254,"column":11}},"103":{"start":{"line":252,"column":27},"end":{"line":252,"column":42}},"104":{"start":{"line":253,"column":12},"end":{"line":253,"column":87}},"105":{"start":{"line":256,"column":10},"end":{"line":256,"column":17}},"106":{"start":{"line":258,"column":8},"end":{"line":258,"column":30}},"107":{"start":{"line":261,"column":6},"end":{"line":261,"column":40}},"108":{"start":{"line":261,"column":27},"end":{"line":261,"column":38}},"109":{"start":{"line":263,"column":6},"end":{"line":269,"column":9}},"110":{"start":{"line":264,"column":8},"end":{"line":268,"column":9}},"111":{"start":{"line":265,"column":10},"end":{"line":265,"column":39}},"112":{"start":{"line":278,"column":6},"end":{"line":278,"column":60}},"113":{"start":{"line":278,"column":47},"end":{"line":278,"column":60}},"114":{"start":{"line":279,"column":6},"end":{"line":279,"column":56}},"115":{"start":{"line":279,"column":43},"end":{"line":279,"column":56}},"116":{"start":{"line":281,"column":21},"end":{"line":281,"column":33}},"117":{"start":{"line":282,"column":6},"end":{"line":287,"column":7}},"118":{"start":{"line":283,"column":22},"end":{"line":283,"column":39}},"119":{"start":{"line":284,"column":8},"end":{"line":284,"column":62}},"120":{"start":{"line":284,"column":49},"end":{"line":284,"column":62}},"121":{"start":{"line":285,"column":8},"end":{"line":285,"column":71}},"122":{"start":{"line":285,"column":58},"end":{"line":285,"column":71}},"123":{"start":{"line":286,"column":8},"end":{"line":286,"column":63}},"124":{"start":{"line":286,"column":50},"end":{"line":286,"column":63}},"125":{"start":{"line":289,"column":6},"end":{"line":289,"column":18}},"126":{"start":{"line":298,"column":6},"end":{"line":298,"column":60}},"127":{"start":{"line":298,"column":47},"end":{"line":298,"column":60}},"128":{"start":{"line":301,"column":6},"end":{"line":303,"column":7}},"129":{"start":{"line":302,"column":8},"end":{"line":302,"column":54}},"130":{"start":{"line":306,"column":6},"end":{"line":306,"column":58}},"131":{"start":{"line":306,"column":45},"end":{"line":306,"column":58}},"132":{"start":{"line":309,"column":6},"end":{"line":309,"column":52}},"133":{"start":{"line":314,"column":2},"end":{"line":314,"column":37}},"134":{"start":{"line":317,"column":2},"end":{"line":319,"column":3}},"135":{"start":{"line":318,"column":4},"end":{"line":318,"column":34}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":14,"column":1},"end":{"line":14,"column":2}},"loc":{"start":{"line":14,"column":19},"end":{"line":320,"column":1}},"line":14},"1":{"name":"(anonymous_1)","decl":{"start":{"line":25,"column":4},"end":{"line":25,"column":5}},"loc":{"start":{"line":25,"column":17},"end":{"line":33,"column":5}},"line":25},"2":{"name":"(anonymous_2)","decl":{"start":{"line":40,"column":4},"end":{"line":40,"column":5}},"loc":{"start":{"line":40,"column":26},"end":{"line":43,"column":5}},"line":40},"3":{"name":"(anonymous_3)","decl":{"start":{"line":51,"column":4},"end":{"line":51,"column":5}},"loc":{"start":{"line":51,"column":24},"end":{"line":119,"column":5}},"line":51},"4":{"name":"(anonymous_4)","decl":{"start":{"line":126,"column":4},"end":{"line":126,"column":5}},"loc":{"start":{"line":126,"column":17},"end":{"line":191,"column":5}},"line":126},"5":{"name":"(anonymous_5)","decl":{"start":{"line":198,"column":4},"end":{"line":198,"column":5}},"loc":{"start":{"line":198,"column":18},"end":{"line":207,"column":5}},"line":198},"6":{"name":"(anonymous_6)","decl":{"start":{"line":214,"column":4},"end":{"line":214,"column":5}},"loc":{"start":{"line":214,"column":32},"end":{"line":222,"column":5}},"line":214},"7":{"name":"(anonymous_7)","decl":{"start":{"line":229,"column":4},"end":{"line":229,"column":5}},"loc":{"start":{"line":229,"column":31},"end":{"line":270,"column":5}},"line":229},"8":{"name":"(anonymous_8)","decl":{"start":{"line":247,"column":29},"end":{"line":247,"column":30}},"loc":{"start":{"line":247,"column":36},"end":{"line":259,"column":7}},"line":247},"9":{"name":"(anonymous_9)","decl":{"start":{"line":261,"column":17},"end":{"line":261,"column":18}},"loc":{"start":{"line":261,"column":27},"end":{"line":261,"column":38}},"line":261},"10":{"name":"(anonymous_10)","decl":{"start":{"line":263,"column":20},"end":{"line":263,"column":21}},"loc":{"start":{"line":263,"column":30},"end":{"line":269,"column":7}},"line":263},"11":{"name":"(anonymous_11)","decl":{"start":{"line":277,"column":4},"end":{"line":277,"column":5}},"loc":{"start":{"line":277,"column":20},"end":{"line":290,"column":5}},"line":277},"12":{"name":"(anonymous_12)","decl":{"start":{"line":297,"column":4},"end":{"line":297,"column":5}},"loc":{"start":{"line":297,"column":19},"end":{"line":310,"column":5}},"line":297}},"branchMap":{"0":{"loc":{"start":{"line":27,"column":8},"end":{"line":27,"column":33}},"type":"if","locations":[{"start":{"line":27,"column":8},"end":{"line":27,"column":33}},{"start":{},"end":{}}],"line":27},"1":{"loc":{"start":{"line":29,"column":15},"end":{"line":29,"column":42}},"type":"binary-expr","locations":[{"start":{"line":29,"column":15},"end":{"line":29,"column":34}},{"start":{"line":29,"column":38},"end":{"line":29,"column":42}}],"line":29},"2":{"loc":{"start":{"line":41,"column":25},"end":{"line":41,"column":90}},"type":"cond-expr","locations":[{"start":{"line":41,"column":59},"end":{"line":41,"column":80}},{"start":{"line":41,"column":83},"end":{"line":41,"column":90}}],"line":41},"3":{"loc":{"start":{"line":41,"column":25},"end":{"line":41,"column":56}},"type":"binary-expr","locations":[{"start":{"line":41,"column":25},"end":{"line":41,"column":31}},{"start":{"line":41,"column":35},"end":{"line":41,"column":56}}],"line":41},"4":{"loc":{"start":{"line":53,"column":6},"end":{"line":61,"column":7}},"type":"if","locations":[{"start":{"line":53,"column":6},"end":{"line":61,"column":7}},{"start":{},"end":{}}],"line":53},"5":{"loc":{"start":{"line":65,"column":17},"end":{"line":65,"column":73}},"type":"cond-expr","locations":[{"start":{"line":65,"column":42},"end":{"line":65,"column":55}},{"start":{"line":65,"column":58},"end":{"line":65,"column":73}}],"line":65},"6":{"loc":{"start":{"line":65,"column":17},"end":{"line":65,"column":39}},"type":"binary-expr","locations":[{"start":{"line":65,"column":17},"end":{"line":65,"column":22}},{"start":{"line":65,"column":26},"end":{"line":65,"column":39}}],"line":65},"7":{"loc":{"start":{"line":66,"column":16},"end":{"line":66,"column":61}},"type":"cond-expr","locations":[{"start":{"line":66,"column":40},"end":{"line":66,"column":52}},{"start":{"line":66,"column":55},"end":{"line":66,"column":61}}],"line":66},"8":{"loc":{"start":{"line":66,"column":16},"end":{"line":66,"column":37}},"type":"binary-expr","locations":[{"start":{"line":66,"column":16},"end":{"line":66,"column":21}},{"start":{"line":66,"column":25},"end":{"line":66,"column":37}}],"line":66},"9":{"loc":{"start":{"line":70,"column":6},"end":{"line":78,"column":7}},"type":"if","locations":[{"start":{"line":70,"column":6},"end":{"line":78,"column":7}},{"start":{},"end":{}}],"line":70},"10":{"loc":{"start":{"line":88,"column":8},"end":{"line":108,"column":9}},"type":"if","locations":[{"start":{"line":88,"column":8},"end":{"line":108,"column":9}},{"start":{},"end":{}}],"line":88},"11":{"loc":{"start":{"line":128,"column":6},"end":{"line":136,"column":7}},"type":"if","locations":[{"start":{"line":128,"column":6},"end":{"line":136,"column":7}},{"start":{},"end":{}}],"line":128},"12":{"loc":{"start":{"line":153,"column":6},"end":{"line":153,"column":28}},"type":"if","locations":[{"start":{"line":153,"column":6},"end":{"line":153,"column":28}},{"start":{},"end":{}}],"line":153},"13":{"loc":{"start":{"line":175,"column":6},"end":{"line":188,"column":7}},"type":"if","locations":[{"start":{"line":175,"column":6},"end":{"line":188,"column":7}},{"start":{},"end":{}}],"line":175},"14":{"loc":{"start":{"line":201,"column":8},"end":{"line":201,"column":29}},"type":"if","locations":[{"start":{"line":201,"column":8},"end":{"line":201,"column":29}},{"start":{},"end":{}}],"line":201},"15":{"loc":{"start":{"line":215,"column":6},"end":{"line":215,"column":31}},"type":"if","locations":[{"start":{"line":215,"column":6},"end":{"line":215,"column":31}},{"start":{},"end":{}}],"line":215},"16":{"loc":{"start":{"line":216,"column":19},"end":{"line":216,"column":67}},"type":"cond-expr","locations":[{"start":{"line":216,"column":52},"end":{"line":216,"column":62}},{"start":{"line":216,"column":65},"end":{"line":216,"column":67}}],"line":216},"17":{"loc":{"start":{"line":217,"column":6},"end":{"line":217,"column":53}},"type":"if","locations":[{"start":{"line":217,"column":6},"end":{"line":217,"column":53}},{"start":{},"end":{}}],"line":217},"18":{"loc":{"start":{"line":218,"column":6},"end":{"line":218,"column":61}},"type":"if","locations":[{"start":{"line":218,"column":6},"end":{"line":218,"column":61}},{"start":{},"end":{}}],"line":218},"19":{"loc":{"start":{"line":219,"column":19},"end":{"line":219,"column":69}},"type":"cond-expr","locations":[{"start":{"line":219,"column":52},"end":{"line":219,"column":62}},{"start":{"line":219,"column":65},"end":{"line":219,"column":69}}],"line":219},"20":{"loc":{"start":{"line":221,"column":13},"end":{"line":221,"column":41}},"type":"binary-expr","locations":[{"start":{"line":221,"column":13},"end":{"line":221,"column":24}},{"start":{"line":221,"column":28},"end":{"line":221,"column":41}}],"line":221},"21":{"loc":{"start":{"line":231,"column":6},"end":{"line":231,"column":27}},"type":"if","locations":[{"start":{"line":231,"column":6},"end":{"line":231,"column":27}},{"start":{},"end":{}}],"line":231},"22":{"loc":{"start":{"line":237,"column":10},"end":{"line":237,"column":27}},"type":"if","locations":[{"start":{"line":237,"column":10},"end":{"line":237,"column":27}},{"start":{},"end":{}}],"line":237},"23":{"loc":{"start":{"line":238,"column":10},"end":{"line":238,"column":58}},"type":"if","locations":[{"start":{"line":238,"column":10},"end":{"line":238,"column":58}},{"start":{},"end":{}}],"line":238},"24":{"loc":{"start":{"line":239,"column":10},"end":{"line":239,"column":55}},"type":"if","locations":[{"start":{"line":239,"column":10},"end":{"line":239,"column":55}},{"start":{},"end":{}}],"line":239},"25":{"loc":{"start":{"line":239,"column":14},"end":{"line":239,"column":44}},"type":"binary-expr","locations":[{"start":{"line":239,"column":14},"end":{"line":239,"column":24}},{"start":{"line":239,"column":28},"end":{"line":239,"column":44}}],"line":239},"26":{"loc":{"start":{"line":251,"column":10},"end":{"line":254,"column":11}},"type":"if","locations":[{"start":{"line":251,"column":10},"end":{"line":254,"column":11}},{"start":{},"end":{}}],"line":251},"27":{"loc":{"start":{"line":253,"column":17},"end":{"line":253,"column":86}},"type":"cond-expr","locations":[{"start":{"line":253,"column":66},"end":{"line":253,"column":82}},{"start":{"line":253,"column":85},"end":{"line":253,"column":86}}],"line":253},"28":{"loc":{"start":{"line":253,"column":17},"end":{"line":253,"column":63}},"type":"binary-expr","locations":[{"start":{"line":253,"column":17},"end":{"line":253,"column":23}},{"start":{"line":253,"column":27},"end":{"line":253,"column":63}}],"line":253},"29":{"loc":{"start":{"line":278,"column":6},"end":{"line":278,"column":60}},"type":"if","locations":[{"start":{"line":278,"column":6},"end":{"line":278,"column":60}},{"start":{},"end":{}}],"line":278},"30":{"loc":{"start":{"line":278,"column":10},"end":{"line":278,"column":45}},"type":"binary-expr","locations":[{"start":{"line":278,"column":10},"end":{"line":278,"column":16}},{"start":{"line":278,"column":20},"end":{"line":278,"column":45}}],"line":278},"31":{"loc":{"start":{"line":279,"column":6},"end":{"line":279,"column":56}},"type":"if","locations":[{"start":{"line":279,"column":6},"end":{"line":279,"column":56}},{"start":{},"end":{}}],"line":279},"32":{"loc":{"start":{"line":279,"column":10},"end":{"line":279,"column":41}},"type":"binary-expr","locations":[{"start":{"line":279,"column":10},"end":{"line":279,"column":24}},{"start":{"line":279,"column":28},"end":{"line":279,"column":41}}],"line":279},"33":{"loc":{"start":{"line":284,"column":8},"end":{"line":284,"column":62}},"type":"if","locations":[{"start":{"line":284,"column":8},"end":{"line":284,"column":62}},{"start":{},"end":{}}],"line":284},"34":{"loc":{"start":{"line":284,"column":12},"end":{"line":284,"column":47}},"type":"binary-expr","locations":[{"start":{"line":284,"column":12},"end":{"line":284,"column":18}},{"start":{"line":284,"column":22},"end":{"line":284,"column":47}}],"line":284},"35":{"loc":{"start":{"line":285,"column":8},"end":{"line":285,"column":71}},"type":"if","locations":[{"start":{"line":285,"column":8},"end":{"line":285,"column":71}},{"start":{},"end":{}}],"line":285},"36":{"loc":{"start":{"line":285,"column":12},"end":{"line":285,"column":56}},"type":"binary-expr","locations":[{"start":{"line":285,"column":12},"end":{"line":285,"column":24}},{"start":{"line":285,"column":28},"end":{"line":285,"column":56}}],"line":285},"37":{"loc":{"start":{"line":286,"column":8},"end":{"line":286,"column":63}},"type":"if","locations":[{"start":{"line":286,"column":8},"end":{"line":286,"column":63}},{"start":{},"end":{}}],"line":286},"38":{"loc":{"start":{"line":298,"column":6},"end":{"line":298,"column":60}},"type":"if","locations":[{"start":{"line":298,"column":6},"end":{"line":298,"column":60}},{"start":{},"end":{}}],"line":298},"39":{"loc":{"start":{"line":298,"column":10},"end":{"line":298,"column":45}},"type":"binary-expr","locations":[{"start":{"line":298,"column":10},"end":{"line":298,"column":16}},{"start":{"line":298,"column":20},"end":{"line":298,"column":45}}],"line":298},"40":{"loc":{"start":{"line":301,"column":6},"end":{"line":303,"column":7}},"type":"if","locations":[{"start":{"line":301,"column":6},"end":{"line":303,"column":7}},{"start":{},"end":{}}],"line":301},"41":{"loc":{"start":{"line":306,"column":6},"end":{"line":306,"column":58}},"type":"if","locations":[{"start":{"line":306,"column":6},"end":{"line":306,"column":58}},{"start":{},"end":{}}],"line":306},"42":{"loc":{"start":{"line":317,"column":2},"end":{"line":319,"column":3}},"type":"if","locations":[{"start":{"line":317,"column":2},"end":{"line":319,"column":3}},{"start":{},"end":{}}],"line":317},"43":{"loc":{"start":{"line":317,"column":6},"end":{"line":317,"column":53}},"type":"binary-expr","locations":[{"start":{"line":317,"column":6},"end":{"line":317,"column":35}},{"start":{"line":317,"column":39},"end":{"line":317,"column":53}}],"line":317},"44":{"loc":{"start":{"line":320,"column":3},"end":{"line":320,"column":54}},"type":"cond-expr","locations":[{"start":{"line":320,"column":35},"end":{"line":320,"column":41}},{"start":{"line":320,"column":44},"end":{"line":320,"column":54}}],"line":320}},"s":{"0":13,"1":13,"2":13,"3":360,"4":360,"5":0,"6":360,"7":1,"8":365,"9":365,"10":150,"11":150,"12":1,"13":1,"14":1,"15":149,"16":149,"17":1,"18":1,"19":1,"20":148,"21":148,"22":148,"23":148,"24":146,"25":2,"26":1,"27":1,"28":1,"29":1,"30":1,"31":1,"32":0,"33":0,"34":0,"35":1,"36":1,"37":1,"38":203,"39":203,"40":0,"41":0,"42":0,"43":203,"44":203,"45":203,"46":203,"47":3,"48":3,"49":3,"50":200,"51":94,"52":106,"53":106,"54":106,"55":2,"56":2,"57":2,"58":2,"59":2,"60":104,"61":104,"62":1,"63":1,"64":1,"65":1,"66":1,"67":103,"68":6,"69":6,"70":6,"71":0,"72":6,"73":6,"74":8,"75":1,"76":7,"77":7,"78":2,"79":5,"80":1,"81":4,"82":4,"83":1,"84":1,"85":0,"86":1,"87":1,"88":1,"89":1,"90":4,"91":4,"92":0,"93":4,"94":1,"95":3,"96":1,"97":2,"98":1,"99":2,"100":2,"101":2,"102":2,"103":2,"104":1,"105":1,"106":2,"107":1,"108":1,"109":1,"110":2,"111":2,"112":1258,"113":1,"114":1257,"115":1,"116":1256,"117":1256,"118":3760,"119":3760,"120":0,"121":3760,"122":3,"123":3757,"124":1,"125":1252,"126":106,"127":0,"128":106,"129":1,"130":105,"131":104,"132":1,"133":13,"134":13,"135":13},"f":{"0":13,"1":360,"2":365,"3":150,"4":203,"5":6,"6":8,"7":1,"8":2,"9":1,"10":2,"11":1258,"12":106},"b":{"0":[0,360],"1":[360,1],"2":[363,2],"3":[365,363],"4":[1,149],"5":[149,0],"6":[149,149],"7":[149,0],"8":[149,149],"9":[1,148],"10":[1,1],"11":[0,203],"12":[94,106],"13":[1,103],"14":[0,6],"15":[1,7],"16":[5,2],"17":[2,5],"18":[1,4],"19":[3,1],"20":[4,3],"21":[0,1],"22":[0,4],"23":[1,3],"24":[1,2],"25":[3,3],"26":[2,0],"27":[1,0],"28":[1,1],"29":[1,1257],"30":[1258,1257],"31":[1,1256],"32":[1257,1257],"33":[0,3760],"34":[3760,3760],"35":[3,3757],"36":[3760,3760],"37":[1,3756],"38":[0,106],"39":[106,106],"40":[1,105],"41":[104,1],"42":[13,0],"43":[13,13],"44":[13,0]},"_coverageSchema":"1a1c01bbd47fc00a2c39e90264f33305004495a9","hash":"0bdeb3223e6c12d545eb028f4f8006e5ef41f697"} -} diff --git a/tests/layout-system/coverage/lcov-report/base.css b/tests/layout-system/coverage/lcov-report/base.css deleted file mode 100644 index f418035..0000000 --- a/tests/layout-system/coverage/lcov-report/base.css +++ /dev/null @@ -1,224 +0,0 @@ -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; -} diff --git a/tests/layout-system/coverage/lcov-report/block-navigation.js b/tests/layout-system/coverage/lcov-report/block-navigation.js deleted file mode 100644 index 530d1ed..0000000 --- a/tests/layout-system/coverage/lcov-report/block-navigation.js +++ /dev/null @@ -1,87 +0,0 @@ -/* 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); diff --git a/tests/layout-system/coverage/lcov-report/favicon.png b/tests/layout-system/coverage/lcov-report/favicon.png deleted file mode 100644 index c1525b811a167671e9de1fa78aab9f5c0b61cef7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 445 zcmV;u0Yd(XP))rP{nL}Ln%S7`m{0DjX9TLF* zFCb$4Oi7vyLOydb!7n&^ItCzb-%BoB`=x@N2jll2Nj`kauio%aw_@fe&*}LqlFT43 z8doAAe))z_%=P%v^@JHp3Hjhj^6*Kr_h|g_Gr?ZAa&y>wxHE99Gk>A)2MplWz2xdG zy8VD2J|Uf#EAw*bo5O*PO_}X2Tob{%bUoO2G~T`@%S6qPyc}VkhV}UifBuRk>%5v( z)x7B{I~z*k<7dv#5tC+m{km(D087J4O%+<<;K|qwefb6@GSX45wCK}Sn*> - - - - Code coverage report for All files - - - - - - - - - -
-
-

All files

-
- -
- 91.83% - Statements - 664/723 -
- - -
- 82.74% - Branches - 446/539 -
- - -
- 100% - Functions - 88/88 -
- - -
- 96.27% - Lines - 620/644 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FileStatementsBranchesFunctionsLines
layout-manager.js -
-
91.99%540/58782.18%369/449100%75/7596.58%509/527
state-manager.js -
-
91.17%124/13685.55%77/90100%13/1394.87%111/117
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/tests/layout-system/coverage/lcov-report/layout-manager.js.html b/tests/layout-system/coverage/lcov-report/layout-manager.js.html deleted file mode 100644 index cd5ac2f..0000000 --- a/tests/layout-system/coverage/lcov-report/layout-manager.js.html +++ /dev/null @@ -1,4306 +0,0 @@ - - - - - - Code coverage report for layout-manager.js - - - - - - - - - -
-
-

All files layout-manager.js

-
- -
- 91.99% - Statements - 540/587 -
- - -
- 82.18% - Branches - 369/449 -
- - -
- 100% - Functions - 75/75 -
- - -
- 96.58% - Lines - 509/527 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 -213 -214 -215 -216 -217 -218 -219 -220 -221 -222 -223 -224 -225 -226 -227 -228 -229 -230 -231 -232 -233 -234 -235 -236 -237 -238 -239 -240 -241 -242 -243 -244 -245 -246 -247 -248 -249 -250 -251 -252 -253 -254 -255 -256 -257 -258 -259 -260 -261 -262 -263 -264 -265 -266 -267 -268 -269 -270 -271 -272 -273 -274 -275 -276 -277 -278 -279 -280 -281 -282 -283 -284 -285 -286 -287 -288 -289 -290 -291 -292 -293 -294 -295 -296 -297 -298 -299 -300 -301 -302 -303 -304 -305 -306 -307 -308 -309 -310 -311 -312 -313 -314 -315 -316 -317 -318 -319 -320 -321 -322 -323 -324 -325 -326 -327 -328 -329 -330 -331 -332 -333 -334 -335 -336 -337 -338 -339 -340 -341 -342 -343 -344 -345 -346 -347 -348 -349 -350 -351 -352 -353 -354 -355 -356 -357 -358 -359 -360 -361 -362 -363 -364 -365 -366 -367 -368 -369 -370 -371 -372 -373 -374 -375 -376 -377 -378 -379 -380 -381 -382 -383 -384 -385 -386 -387 -388 -389 -390 -391 -392 -393 -394 -395 -396 -397 -398 -399 -400 -401 -402 -403 -404 -405 -406 -407 -408 -409 -410 -411 -412 -413 -414 -415 -416 -417 -418 -419 -420 -421 -422 -423 -424 -425 -426 -427 -428 -429 -430 -431 -432 -433 -434 -435 -436 -437 -438 -439 -440 -441 -442 -443 -444 -445 -446 -447 -448 -449 -450 -451 -452 -453 -454 -455 -456 -457 -458 -459 -460 -461 -462 -463 -464 -465 -466 -467 -468 -469 -470 -471 -472 -473 -474 -475 -476 -477 -478 -479 -480 -481 -482 -483 -484 -485 -486 -487 -488 -489 -490 -491 -492 -493 -494 -495 -496 -497 -498 -499 -500 -501 -502 -503 -504 -505 -506 -507 -508 -509 -510 -511 -512 -513 -514 -515 -516 -517 -518 -519 -520 -521 -522 -523 -524 -525 -526 -527 -528 -529 -530 -531 -532 -533 -534 -535 -536 -537 -538 -539 -540 -541 -542 -543 -544 -545 -546 -547 -548 -549 -550 -551 -552 -553 -554 -555 -556 -557 -558 -559 -560 -561 -562 -563 -564 -565 -566 -567 -568 -569 -570 -571 -572 -573 -574 -575 -576 -577 -578 -579 -580 -581 -582 -583 -584 -585 -586 -587 -588 -589 -590 -591 -592 -593 -594 -595 -596 -597 -598 -599 -600 -601 -602 -603 -604 -605 -606 -607 -608 -609 -610 -611 -612 -613 -614 -615 -616 -617 -618 -619 -620 -621 -622 -623 -624 -625 -626 -627 -628 -629 -630 -631 -632 -633 -634 -635 -636 -637 -638 -639 -640 -641 -642 -643 -644 -645 -646 -647 -648 -649 -650 -651 -652 -653 -654 -655 -656 -657 -658 -659 -660 -661 -662 -663 -664 -665 -666 -667 -668 -669 -670 -671 -672 -673 -674 -675 -676 -677 -678 -679 -680 -681 -682 -683 -684 -685 -686 -687 -688 -689 -690 -691 -692 -693 -694 -695 -696 -697 -698 -699 -700 -701 -702 -703 -704 -705 -706 -707 -708 -709 -710 -711 -712 -713 -714 -715 -716 -717 -718 -719 -720 -721 -722 -723 -724 -725 -726 -727 -728 -729 -730 -731 -732 -733 -734 -735 -736 -737 -738 -739 -740 -741 -742 -743 -744 -745 -746 -747 -748 -749 -750 -751 -752 -753 -754 -755 -756 -757 -758 -759 -760 -761 -762 -763 -764 -765 -766 -767 -768 -769 -770 -771 -772 -773 -774 -775 -776 -777 -778 -779 -780 -781 -782 -783 -784 -785 -786 -787 -788 -789 -790 -791 -792 -793 -794 -795 -796 -797 -798 -799 -800 -801 -802 -803 -804 -805 -806 -807 -808 -809 -810 -811 -812 -813 -814 -815 -816 -817 -818 -819 -820 -821 -822 -823 -824 -825 -826 -827 -828 -829 -830 -831 -832 -833 -834 -835 -836 -837 -838 -839 -840 -841 -842 -843 -844 -845 -846 -847 -848 -849 -850 -851 -852 -853 -854 -855 -856 -857 -858 -859 -860 -861 -862 -863 -864 -865 -866 -867 -868 -869 -870 -871 -872 -873 -874 -875 -876 -877 -878 -879 -880 -881 -882 -883 -884 -885 -886 -887 -888 -889 -890 -891 -892 -893 -894 -895 -896 -897 -898 -899 -900 -901 -902 -903 -904 -905 -906 -907 -908 -909 -910 -911 -912 -913 -914 -915 -916 -917 -918 -919 -920 -921 -922 -923 -924 -925 -926 -927 -928 -929 -930 -931 -932 -933 -934 -935 -936 -937 -938 -939 -940 -941 -942 -943 -944 -945 -946 -947 -948 -949 -950 -951 -952 -953 -954 -955 -956 -957 -958 -959 -960 -961 -962 -963 -964 -965 -966 -967 -968 -969 -970 -971 -972 -973 -974 -975 -976 -977 -978 -979 -980 -981 -982 -983 -984 -985 -986 -987 -988 -989 -990 -991 -992 -993 -994 -995 -996 -997 -998 -999 -1000 -1001 -1002 -1003 -1004 -1005 -1006 -1007 -1008 -1009 -1010 -1011 -1012 -1013 -1014 -1015 -1016 -1017 -1018 -1019 -1020 -1021 -1022 -1023 -1024 -1025 -1026 -1027 -1028 -1029 -1030 -1031 -1032 -1033 -1034 -1035 -1036 -1037 -1038 -1039 -1040 -1041 -1042 -1043 -1044 -1045 -1046 -1047 -1048 -1049 -1050 -1051 -1052 -1053 -1054 -1055 -1056 -1057 -1058 -1059 -1060 -1061 -1062 -1063 -1064 -1065 -1066 -1067 -1068 -1069 -1070 -1071 -1072 -1073 -1074 -1075 -1076 -1077 -1078 -1079 -1080 -1081 -1082 -1083 -1084 -1085 -1086 -1087 -1088 -1089 -1090 -1091 -1092 -1093 -1094 -1095 -1096 -1097 -1098 -1099 -1100 -1101 -1102 -1103 -1104 -1105 -1106 -1107 -1108 -1109 -1110 -1111 -1112 -1113 -1114 -1115 -1116 -1117 -1118 -1119 -1120 -1121 -1122 -1123 -1124 -1125 -1126 -1127 -1128 -1129 -1130 -1131 -1132 -1133 -1134 -1135 -1136 -1137 -1138 -1139 -1140 -1141 -1142 -1143 -1144 -1145 -1146 -1147 -1148 -1149 -1150 -1151 -1152 -1153 -1154 -1155 -1156 -1157 -1158 -1159 -1160 -1161 -1162 -1163 -1164 -1165 -1166 -1167 -1168 -1169 -1170 -1171 -1172 -1173 -1174 -1175 -1176 -1177 -1178 -1179 -1180 -1181 -1182 -1183 -1184 -1185 -1186 -1187 -1188 -1189 -1190 -1191 -1192 -1193 -1194 -1195 -1196 -1197 -1198 -1199 -1200 -1201 -1202 -1203 -1204 -1205 -1206 -1207 -1208 -1209 -1210 -1211 -1212 -1213 -1214 -1215 -1216 -1217 -1218 -1219 -1220 -1221 -1222 -1223 -1224 -1225 -1226 -1227 -1228 -1229 -1230 -1231 -1232 -1233 -1234 -1235 -1236 -1237 -1238 -1239 -1240 -1241 -1242 -1243 -1244 -1245 -1246 -1247 -1248 -1249 -1250 -1251 -1252 -1253 -1254 -1255 -1256 -1257 -1258 -1259 -1260 -1261 -1262 -1263 -1264 -1265 -1266 -1267 -1268 -1269 -1270 -1271 -1272 -1273 -1274 -1275 -1276 -1277 -1278 -1279 -1280 -1281 -1282 -1283 -1284 -1285 -1286 -1287 -1288 -1289 -1290 -1291 -1292 -1293 -1294 -1295 -1296 -1297 -1298 -1299 -1300 -1301 -1302 -1303 -1304 -1305 -1306 -1307 -1308 -1309 -1310 -1311 -1312 -1313 -1314 -1315 -1316 -1317 -1318 -1319 -1320 -1321 -1322 -1323 -1324 -1325 -1326 -1327 -1328 -1329 -1330 -1331 -1332 -1333 -1334 -1335 -1336 -1337 -1338 -1339 -1340 -1341 -1342 -1343 -1344 -1345 -1346 -1347 -1348 -1349 -1350 -1351 -1352 -1353 -1354 -1355 -1356 -1357 -1358 -1359 -1360 -1361 -1362 -1363 -1364 -1365 -1366 -1367 -1368 -1369 -1370 -1371 -1372 -1373 -1374 -1375 -1376 -1377 -1378 -1379 -1380 -1381 -1382 -1383 -1384 -1385 -1386 -1387 -1388 -1389 -1390 -1391 -1392 -1393 -1394 -1395 -1396 -1397 -1398 -1399 -1400 -1401 -1402 -1403 -1404 -1405 -1406 -1407 -1408  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -12x -  -  -  -12x -  -12x -  -  -  -  -  -12x -  -  -  -  -  -  -  -  -  -  -  -  -  -12x -  -  -  -  -  -  -  -  -12x -  -  -  -  -  -  -  -  -  -  -  -97x -  -  -97x -  -  -97x -  -  -97x -  -  -97x -  -  -97x -  -  -97x -  -  -97x -  -  -97x -  -  -97x -  -  -97x -  -  -97x -  -  -97x -  -  -97x -  -  -97x -97x -97x -97x -97x -97x -97x -97x -97x -  -  -97x -  -97x -  -97x -  -  -  -  -  -  -  -97x -  -  -97x -97x -  -  -291x -  -  -  -97x -97x -291x -  -  -  -97x -291x -291x -  -  -291x -291x -  -  -  -97x -291x -291x -  -  -  -97x -  -  -97x -97x -  -96x -  -  -96x -  -  -  -97x -97x -85x -  -97x -97x -  -  -  -97x -97x -85x -  -97x -97x -  -  -  -97x -97x -  -  -97x -  -  -  -  -  -  -  -97x -97x -86x -  -  -  -11x -  -11x -11x -11x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -194x -  -194x -  -31x -31x -13x -13x -  -  -  -36x -1x -1x -  -36x -  -  -2x -1x -1x -  -  -  -  -  -  -  -  -  -  -  -43x -43x -129x -  -  -129x -  -129x -  -  -129x -  -  -129x -129x -  -  -  -129x -  -  -  -129x -  -  -43x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -142x -142x -568x -3x -3x -  -  -  -142x -  -142x -142x -142x -  -  -142x -  -  -142x -142x -  -  -  -142x -  -  -142x -142x -142x -  -  -  -142x -142x -142x -  -  -  -  -139x -  -  -  -  -  -  -  -43x -  -43x -43x -43x -  -  -  -  -  -  -  -36x -  -  -36x -  -  -  -  -  -  -  -  -  -  -  -  -97x -  -97x -97x -97x -  -1x -1x -  -  -1x -1x -  -  -1x -  -  -  -  -  -  -  -  -98x -  -  -98x -  -  -  -  -98x -98x -98x -  -98x -67x -67x -  -  -  -  -  -98x -98x -  -  -  -  -  -  -  -  -  -  -  -  -2x -  -  -2x -  -  -  -  -2x -2x -2x -  -  -2x -1x -1x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -2x -2x -  -  -2x -2x -2x -2x -  -  -  -  -  -2x -  -2x -2x -6x -6x -  -  -6x -6x -  -  -6x -  -  -6x -  -  -  -2x -2x -  -  -2x -  -  -  -  -  -  -  -1x -1x -  -1x -3x -3x -3x -  -  -3x -3x -3x -3x -3x -3x -  -  -  -3x -  -  -3x -1x -1x -  -2x -2x -  -  -  -  -  -  -  -  -  -  -9x -9x -  -9x -9x -  -9x -1x -1x -1x -1x -1x -  -  -8x -8x -8x -8x -  -  -  -  -  -  -  -  -1024x -  -389x -  -324x -  -310x -  -1x -  -  -  -  -  -  -  -  -9x -9x -9x -  -7x -7x -  -  -  -  -  -  -  -98x -  -98x -294x -294x -  -294x -  -  -294x -291x -291x -291x -291x -291x -291x -  -  -  -291x -  -  -  -  -291x -291x -291x -291x -  -291x -  -  -294x -294x -294x -294x -  -294x -  -  -  -  -  -  -  -  -5x -5x -5x -  -3x -3x -  -  -  -  -  -  -  -3x -2x -2x -  -1x -1x -  -  -  -  -  -  -  -  -  -  -165x -165x -  -165x -495x -495x -495x -  -495x -69x -69x -  -426x -  -  -  -  -  -  -  -  -  -165x -  -  -165x -165x -  -  -  -  -  -  -  -  -374x -  -165x -  -105x -  -103x -  -1x -  -  -  -  -  -  -  -  -  -59x -  -54x -  -2x -  -2x -  -1x -  -  -  -  -  -  -  -  -  -68x -63x -  -62x -62x -62x -  -61x -  -  -61x -61x -60x -  -  -  -61x -61x -61x -  -  -  -  -  -  -  -  -  -  -  -100x -300x -300x -  -300x -2x -  -298x -  -  -  -  -  -  -  -  -  -  -315x -315x -  -315x -315x -315x -315x -  -  -  -  -  -  -  -315x -315x -315x -  -315x -315x -315x -  -315x -  -  -  -  -  -  -  -  -  -  -50x -50x -50x -  -50x -50x -  -  -  -  -  -  -  -  -  -132x -  -  -  -  -  -1x -  -  -  -131x -  -  -  -  -  -  -  -  -  -  -  -  -52x -52x -  -  -50x -24x -24x -2x -2x -  -  -  -  -48x -26x -  -22x -  -  -48x -48x -48x -  -48x -48x -48x -48x -  -  -  -48x -  -  -  -48x -  -  -  -48x -22x -22x -22x -  -  -  -  -48x -22x -  -  -  -  -  -  -  -  -19x -19x -  -  -17x -  -17x -17x -17x -  -17x -17x -17x -17x -  -  -  -17x -  -  -  -17x -  -17x -12x -  -  -  -  -17x -12x -  -  -  -  -  -  -  -  -  -27x -27x -81x -81x -81x -74x -  -  -27x -  -  -  -  -  -  -  -  -4x -4x -4x -4x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -15x -15x -  -13x -3x -  -10x -  -  -  -  -  -  -  -9x -  -  -  -9x -  -  -  -  -  -  -  -  -  -  -  -  -106x -  -106x -106x -  -106x -106x -106x -  -106x -6x -  -100x -  -  -106x -4x -  -102x -  -  -106x -3x -  -103x -  -  -  -  -  -  -  -  -13x -13x -  -  -13x -  -  -12x -  -11x -  -  -  -  -  -  -  -305x -305x -  -305x -  -304x -304x -  -5x -5x -  -  -  -  -  -  -  -  -9x -  -9x -9x -  -9x -9x -9x -  -9x -9x -9x -  -9x -9x -  -9x -  -9x -9x -9x -  -  -9x -9x -  -  -  -  -  -  -  -  -  -17x -  -16x -16x -16x -  -15x -15x -  -14x -  -12x -  -  -12x -12x -  -  -12x -12x -12x -12x -12x -12x -  -12x -12x -12x -  -12x -12x -  -12x -12x -  -  -  -  -  -  -  -50x -  -49x -  -49x -39x -30x -30x -  -30x -30x -  -  -  -  -  -30x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -13x -  -  -12x -9x -9x -  -9x -9x -  -  -  -  -  -9x -  -  -  -  -  -  -  -  -  -  -12x -12x -12x -  -12x -12x -12x -12x -  -  -12x -12x -  -12x -12x -12x -  -  -  -12x -12x -  -  -  -  -  -  -  -  -  -  -  -  -52x -52x -  -52x -52x -  -  -52x -  -  -52x -52x -52x -52x -52x -52x -52x -52x -  -  -52x -  -  -  -  -  -  -  -  -55x -55x -165x -110x -110x -  -55x -  -  -  -  -  -  -  -54x -53x -  -  -53x -1x -  -  -53x -52x -  -1x -  -  -  -  -  -  -  -52x -  -  -52x -52x -  -  -  -  -  -  -  -  -  -134x -  -134x -  -  -134x -134x -  -3x -2x -  -  -2x -1x -  -1x -1x -  -  -  -1x -  -  -  -  -  -  -  -  -804x -584x -584x -582x -582x -  -  -  -  -12x -  -  -12x -12x -  -  - 
/**
- * LayoutManager - 可调整布局系统的前端管理器
- *
- * 职责(按文档逐步实现):
- * - 绑定 resizer 拖动事件,实时更新 CSS 变量(TASK-02-002 ~)
- * - 处理面板折叠/展开(TASK-03-001 ~)
- * - 与 StateManager 协作进行状态持久化(TASK-04-001 ~)
- * - 处理窗口断点自适应与键盘可访问性(TASK-06-001 ~)
- *
- * 当前文件实现范围:TASK-02-001(基础结构 + init + 宽度上下限 + 更新宽度)。
- *
- * 兼容性说明:
- * - 浏览器环境:通过 <script> 引入后,LayoutManager 挂载到 window.LayoutManager。
- * - 测试/Node 环境:如果存在 module.exports,则导出 LayoutManager 以便 Jest 直接 require。
- */
- 
-(function (global) {
-  'use strict';
- 
-  // 为避免在 Jest/jsdom 多次 init() 时累积 beforeunload 监听器,这里用模块级变量记录最后一次绑定的处理函数。
-  let LAST_BEFORE_UNLOAD_HANDLER = null;
-  // 同上:避免多次 init() 叠加 window.resize 监听器导致测试/运行时重复执行。
-  let LAST_WINDOW_RESIZE_HANDLER = null;
- 
-  /**
-   * 将 data-panel 值映射到 CSS 变量名。
-   * 注意:data-panel 使用复数(groups/accounts/emails),CSS 变量按 TDD 使用单数/固定命名。
-   */
-  const PANEL_WIDTH_VAR_MAP = {
-    groups: '--group-panel-width',
-    accounts: '--account-panel-width',
-    emails: '--email-list-panel-width'
-  };
- 
-  /**
-   * 面板“布局生效宽度”CSS 变量映射。
-   *
-   * 说明:
-   * - width 变量(--*-panel-width)用于“保存的宽度”(用于展开恢复/状态持久化)。
-   * - layout-width 变量(--*-panel-layout-width)用于“当前参与 Grid 计算的宽度”。
-   *   折叠时该值会变为 0,从而触发 Grid 列宽动画,但保存宽度不丢失。
-   */
-  const PANEL_LAYOUT_WIDTH_VAR_MAP = {
-    groups: '--group-panel-layout-width',
-    accounts: '--account-panel-layout-width',
-    emails: '--email-list-panel-layout-width'
-  };
- 
-  /**
-   * 面板“布局生效最小宽度”CSS 变量映射(折叠时需要允许列宽收缩到 0)
-   */
-  const PANEL_LAYOUT_MIN_WIDTH_VAR_MAP = {
-    groups: '--group-panel-layout-min-width',
-    accounts: '--account-panel-layout-min-width',
-    emails: '--email-list-panel-layout-min-width'
-  };
- 
-  /**
-   * LayoutManager
-   */
-  class LayoutManager {
-    constructor() {
-      /** @type {boolean} 是否处于拖动中(后续任务使用) */
-      this.isResizing = false;
- 
-      /** @type {HTMLElement|null} 主容器 */
-      this.container = null;
- 
-      /** @type {Map<string, HTMLElement>} data-panel -> panel element */
-      this.panels = new Map();
- 
-      /** @type {Map<string, HTMLElement>} data-resizer -> resizer element */
-      this.resizers = new Map();
- 
-      /** @type {Map<string, HTMLElement>} data-panel -> collapsed indicator element */
-      this.indicators = new Map();
- 
-      /** @type {any|null} 状态管理器实例(TASK-04-001) */
-      this.stateManager = null;
- 
-      /** @type {{debounced: Function, flush: Function}|null} 保存防抖器(TASK-04-002) */
-      this.saveDebouncer = null;
- 
-      /** @type {HTMLElement|null} 当前正在调整的面板 */
-      this.currentPanel = null;
- 
-      /** @type {HTMLElement|null} 当前正在拖动的 resizer */
-      this.currentResizer = null;
- 
-      /** @type {string|null} 当前正在调整的面板类型(data-panel 值) */
-      this.currentPanelType = null;
- 
-      /** @type {number} 拖动起始 X 坐标 */
-      this.startX = 0;
- 
-      /** @type {number} 拖动起始宽度(px) */
-      this.startWidth = 0;
- 
-      /** @type {number} requestAnimationFrame id */
-      this.rafId = 0;
- 
-      /** @type {number} resize 期间最新的 clientX */
-      this.pendingClientX = 0;
- 
-      // 绑定事件处理函数(避免反复 bind 造成性能开销)
-      this._onStartResize = this.startResize.bind(this);
-      this._onResize = this.resize.bind(this);
-      this._onStopResize = this.stopResize.bind(this);
-      this._onCollapseClick = this.onCollapseButtonClick.bind(this);
-      this._onIndicatorClick = this.onIndicatorClick.bind(this);
-      this._onIndicatorKeydown = this.onIndicatorKeydown.bind(this);
-      this._onBeforeUnload = this.saveStateNow.bind(this);
-      this._onResizerKeydown = this.onResizerKeydown.bind(this);
-      this._onWindowResize = this.onWindowResize.bind(this);
- 
-      // 初始化状态管理器(浏览器环境优先使用 window.StateManager;Node/Jest 环境回退 require)
-      this.stateManager = this.createStateManager();
-      // 初始化防抖保存(拖动结束 500ms 后保存)
-      this.saveDebouncer = this.debounce(() => this.saveState(), 500);
-      // 窗口 resize 防抖(200ms,FD F5.1)
-      this.windowResizeDebouncer = this.debounce(() => this.handleWindowResize(), 200);
-    }
- 
-    /**
-     * 初始化(缓存 DOM 引用;事件绑定在后续 TASK 中实现)
-     * @returns {LayoutManager}
-     */
-    init() {
-      this.container = document.querySelector('.main-container');
- 
-      // 缓存可调整面板
-      this.panels.clear();
-      document
-        .querySelectorAll('.resizable-panel[data-panel]')
-        .forEach((panel) => {
-          this.panels.set(panel.dataset.panel, panel);
-        });
- 
-      // 缓存 resizer
-      this.resizers.clear();
-      document.querySelectorAll('.resizer[data-resizer]').forEach((resizer) => {
-        this.resizers.set(resizer.dataset.resizer, resizer);
-      });
- 
-      // 绑定 resizer 的 mousedown 事件(mousemove/mouseup 在 startResize 中绑定到 document)
-      this.resizers.forEach((resizer) => {
-        resizer.removeEventListener('mousedown', this._onStartResize);
-        resizer.addEventListener('mousedown', this._onStartResize);
- 
-        // 键盘支持(F6.1):方向键调整宽度
-        resizer.removeEventListener('keydown', this._onResizerKeydown);
-        resizer.addEventListener('keydown', this._onResizerKeydown);
-      });
- 
-      // 绑定折叠按钮点击事件(TASK-03-002)
-      document.querySelectorAll('.collapse-btn[data-panel]').forEach((btn) => {
-        btn.removeEventListener('click', this._onCollapseClick);
-        btn.addEventListener('click', this._onCollapseClick);
-      });
- 
-      // 初始化折叠指示器(TASK-03-004)
-      this.ensureCollapsedIndicators();
- 
-      // 加载保存的状态(TASK-04-003)
-      const loaded = this.loadState();
-      if (!loaded) {
-        // 初始化“布局生效”CSS 变量(避免折叠/展开后在无 CSS 环境下状态不一致;也便于测试)
-        this.syncAllPanelsLayoutVars();
- 
-        // 初始化指示器偏移(避免多个折叠指示器重叠)
-        this.updateCollapsedIndicatorOffsets();
-      }
- 
-      // 页面卸载时强制保存(TASK-04-002)
-      Eif (global && typeof global.addEventListener === 'function') {
-        if (LAST_BEFORE_UNLOAD_HANDLER) {
-          global.removeEventListener('beforeunload', LAST_BEFORE_UNLOAD_HANDLER);
-        }
-        LAST_BEFORE_UNLOAD_HANDLER = this._onBeforeUnload;
-        global.addEventListener('beforeunload', LAST_BEFORE_UNLOAD_HANDLER);
-      }
- 
-      // 窗口尺寸适配(F5):resize 监听 + 初始适配
-      Eif (global && typeof global.addEventListener === 'function') {
-        if (LAST_WINDOW_RESIZE_HANDLER) {
-          global.removeEventListener('resize', LAST_WINDOW_RESIZE_HANDLER);
-        }
-        LAST_WINDOW_RESIZE_HANDLER = this._onWindowResize;
-        global.addEventListener('resize', LAST_WINDOW_RESIZE_HANDLER);
-      }
- 
-      // 初始适配:避免页面加载时闪烁
-      this.withNoTransition(() => {
-        this.handleWindowResize();
-      });
- 
-      return this;
-    }
- 
-    /**
-     * 创建 StateManager 实例(存在则返回,否则返回 null)
-     * @returns {any|null}
-     */
-    createStateManager() {
-      try {
-        if (global && typeof global.StateManager === 'function') {
-          return new global.StateManager();
-        }
- 
-        // Jest/Node 环境下可直接 require(浏览器中 require 不存在)
-        Eif (typeof require !== 'undefined') {
-          // eslint-disable-next-line global-require, import/no-dynamic-require
-          const StateManagerCtor = require('./state-manager.js');
-          Eif (typeof StateManagerCtor === 'function') {
-            return new StateManagerCtor();
-          }
-        }
-      } catch (e) {
-        try {
-          // eslint-disable-next-line no-console
-          console.warn('[LayoutManager] createStateManager failed:', e);
-        } catch (_) {
-          // ignore
-        }
-      }
-      return null;
-    }
- 
-    /**
-     * 防抖工具(返回 { debounced, flush }),用于减少 localStorage 写入次数
-     * @param {Function} func
-     * @param {number} delay
-     * @returns {{debounced: Function, flush: Function}}
-     */
-    debounce(func, delay) {
-      let timerId = null;
- 
-      return {
-        debounced: (...args) => {
-          if (timerId) clearTimeout(timerId);
-          timerId = setTimeout(() => {
-            timerId = null;
-            func(...args);
-          }, delay);
-        },
-        flush: (...args) => {
-          if (timerId) {
-            clearTimeout(timerId);
-            timerId = null;
-          }
-          func(...args);
-        },
-        cancel: () => {
-          if (timerId) {
-            clearTimeout(timerId);
-            timerId = null;
-          }
-        }
-      };
-    }
- 
-    /**
-     * 构建当前布局状态对象(LayoutState)
-     * @param {string} userId
-     * @returns {Record<string, any>}
-     */
-    buildLayoutState(userId) {
-      const panels = {};
-      ['groups', 'accounts', 'emails'].forEach((panelType) => {
-        const panel = this.panels.get(panelType);
- 
-        // width:始终保存“保存宽度”变量(不是 0px 的 layout-width)
-        const widthVar = PANEL_WIDTH_VAR_MAP[panelType];
-        const rawWidth =
-          (widthVar &&
-            getComputedStyle(document.documentElement).getPropertyValue(widthVar)) ||
-          '';
-        const parsedWidth = this.parsePx(rawWidth);
- 
-        // 若无法解析,则回退到默认值(与 layout.css 一致)
-        const defaultWidthMap = { groups: 200, accounts: 260, emails: 380 };
-        const width = `${parsedWidth !== null ? parsedWidth : defaultWidthMap[panelType]}px`;
- 
-        // collapsed:仅保存用户手动折叠状态;自动折叠(data-autoCollapsed)不持久化
-        const isCollapsed =
-          !!panel &&
-          panel.classList.contains('collapsed') &&
-          panel.dataset.autoCollapsed !== 'true';
- 
-        panels[panelType] = { width, collapsed: isCollapsed };
-      });
- 
-      return {
-        version: '1.1',
-        userId: userId || 'guest',
-        timestamp: Date.now(),
-        panels
-      };
-    }
- 
-    /**
-     * 获取当前用户 ID(TASK-04-004)
-     *
-     * 说明(按 FD F4.3 / PRD 3.4.3):
-     * - localStorage key 需要包含用户 ID,以支持同一浏览器内的多用户隔离。
-     * - 当前项目未必有完整的用户系统,因此这里提供多种“可选来源”,若均不可用则回退为 guest。
-     *
-     * 来源优先级:
-     * 1) 页面元素:<body data-user-id="..."> 或 <html data-user-id="...">
-     * 2) 全局变量:window.currentUserId
-     * 3) meta:<meta name="outlook-user-id" content="...">
-     *
-     * @returns {string|null}
-     */
-    getCurrentUserId() {
-      try {
-        const normalize = (value) => {
-          if (value === null || value === undefined) return null;
-          const text = String(value).trim();
-          return text ? text : null;
-        };
- 
-        // 1) data-user-id(推荐:无需内联脚本即可由后端模板注入)
-        Eif (typeof document !== 'undefined') {
-          const bodyId =
-            document.body && document.body.dataset ? document.body.dataset.userId : null;
-          const normalizedBodyId = normalize(bodyId);
-          Iif (normalizedBodyId) return normalizedBodyId;
- 
-          const htmlId =
-            document.documentElement && document.documentElement.dataset
-              ? document.documentElement.dataset.userId
-              : null;
-          const normalizedHtmlId = normalize(htmlId);
-          Iif (normalizedHtmlId) return normalizedHtmlId;
- 
-          // 3) meta
-          const meta =
-            typeof document.querySelector === 'function'
-              ? document.querySelector('meta[name="outlook-user-id"]')
-              : null;
-          const metaId = meta ? meta.getAttribute('content') : null;
-          const normalizedMetaId = normalize(metaId);
-          Iif (normalizedMetaId) return normalizedMetaId;
-        }
- 
-        // 2) 全局变量
-        const globalId = global ? global.currentUserId : null;
-        const normalizedGlobalId = normalize(globalId);
-        if (normalizedGlobalId) return normalizedGlobalId;
-      } catch (_) {
-        // ignore
-      }
- 
-      return null;
-    }
- 
-    /**
-     * 保存当前布局状态到 localStorage(TASK-04-002)
-     * @returns {boolean}
-     */
-    saveState() {
-      Iif (!this.stateManager || typeof this.stateManager.save !== 'function') return false;
- 
-      const userId = this.getCurrentUserId() || 'guest';
-      const state = this.buildLayoutState(userId);
-      return !!this.stateManager.save(userId, state);
-    }
- 
-    /**
-     * 立即保存(用于 beforeunload / 折叠等需要“立刻落盘”的场景)
-     * @returns {boolean}
-     */
-    saveStateNow() {
-      Iif (!this.saveDebouncer) return this.saveState();
- 
-      // flush 会清理 pending timer,并立即执行保存
-      return !!this.saveDebouncer.flush();
-    }
- 
-    /**
-     * 从 localStorage 加载并应用状态(TASK-04-003)
-     *
-     * 说明:
-     * - StateManager 会负责 validate/migrate;此处只应用已验证的数据。
-     * - 恢复时应禁用动画,避免页面加载时闪烁(FD F4.2)。
-     *
-     * @returns {boolean} 是否成功加载并应用
-     */
-    loadState() {
-      Iif (!this.stateManager || typeof this.stateManager.load !== 'function') return false;
- 
-      const userId = this.getCurrentUserId() || 'guest';
-      const state = this.stateManager.load(userId);
-      if (!state || !state.panels) return false;
- 
-      this.withNoTransition(() => {
-        this.applyLayoutState(state);
- 
-        // 应用完 collapsed 类后,再同步布局变量与指示器偏移(避免过渡动画)
-        this.syncAllPanelsLayoutVars();
-        this.updateCollapsedIndicatorOffsets();
-      });
- 
-      return true;
-    }
- 
-    /**
-     * 临时禁用布局相关 transition,用于状态恢复时避免闪烁
-     * @param {Function} fn
-     */
-    withNoTransition(fn) {
-      const root =
-        typeof document !== 'undefined' && document.documentElement
-          ? document.documentElement
-          : null;
-      Iif (!root || !root.classList) {
-        fn();
-        return;
-      }
- 
-      root.classList.add('layout-no-transition');
-      try {
-        fn();
-      } finally {
-        const remove = () => {
-          try {
-            root.classList.remove('layout-no-transition');
-          } catch (_) {
-            // ignore
-          }
-        };
- 
-        if (typeof requestAnimationFrame === 'function') {
-          requestAnimationFrame(remove);
-        } else E{
-          setTimeout(remove, 0);
-        }
-      }
-    }
- 
-    /**
-     * 临时开启“重置动画”时长(300ms),用于恢复默认布局(FD F3.4)
-     * @param {Function} fn
-     */
-    withResettingAnimation(fn) {
-      const root =
-        typeof document !== 'undefined' && document.documentElement
-          ? document.documentElement
-          : null;
-      Iif (!root || !root.classList) {
-        fn();
-        return;
-      }
- 
-      root.classList.add('layout-resetting');
-      try {
-        fn();
-      } finally {
-        // 保持一小段时间,确保过渡动画完整执行;不依赖 transitionend(避免多属性触发复杂度)
-        setTimeout(() => {
-          try {
-            root.classList.remove('layout-resetting');
-          } catch (_) {
-            // ignore
-          }
-        }, 350);
-      }
-    }
- 
-    /**
-     * 恢复默认布局(F3)
-     *
-     * 说明:
-     * - 清除 localStorage 中保存的布局状态
-     * - 恢复默认宽度(200/260/380)并展开所有侧边栏面板
-     * - 不主动写回 localStorage(保持“清除状态”的语义)
-     *
-     * @returns {void}
-     */
-    resetLayout() {
-      // 避免 reset 过程中触发“拖动后 500ms 保存”的残留定时器把默认值又写回 storage
-      Eif (this.saveDebouncer && typeof this.saveDebouncer.cancel === 'function') {
-        this.saveDebouncer.cancel();
-      }
- 
-      const userId = this.getCurrentUserId() || 'guest';
-      try {
-        Eif (this.stateManager && typeof this.stateManager.clear === 'function') {
-          this.stateManager.clear(userId);
-        }
-      } catch (_) {
-        // ignore
-      }
- 
-      const defaultWidthMap = { groups: 200, accounts: 260, emails: 380 };
- 
-      this.withResettingAnimation(() => {
-        ['groups', 'accounts', 'emails'].forEach((panelType) => {
-          const panel = this.panels.get(panelType);
-          Iif (!panel) return;
- 
-          // 展开所有面板,并清理自动折叠标记
-          delete panel.dataset.autoCollapsed;
-          panel.classList.remove('collapsed');
- 
-          // 恢复默认宽度(同时更新 aria-valuenow)
-          this.updatePanelWidth(panel, defaultWidthMap[panelType]);
- 
-          // 同步按钮的 ARIA/图标(避免依赖 click 流程导致保存)
-          this.updateCollapseButtonState(panelType, false);
-        });
- 
-        // 同步布局变量与指示器偏移(确保 Grid 列宽立即恢复)
-        this.syncAllPanelsLayoutVars();
-        this.updateCollapsedIndicatorOffsets();
-      });
- 
-      this.notify('已恢复默认布局', 'success');
-    }
- 
-    /**
-     * 将已加载的 LayoutState 应用到 DOM/CSS 变量(不触发保存)
-     * @param {Record<string, any>} state
-     */
-    applyLayoutState(state) {
-      const panels = state && state.panels ? state.panels : null;
-      Iif (!panels) return;
- 
-      ['groups', 'accounts', 'emails'].forEach((panelType) => {
-        const panel = this.panels.get(panelType);
-        const panelState = panels[panelType];
-        Iif (!panel || !panelState) return;
- 
-        // width:恢复保存宽度(并裁剪到最小/最大范围,避免异常数据破坏布局)
-        const parsedWidth = this.parsePx(panelState.width);
-        Eif (parsedWidth !== null) {
-          const min = this.getMinWidth(panelType);
-          const max = this.getMaxWidth(panelType);
-          const clamped = Math.max(min, Math.min(max, parsedWidth));
-          this.updatePanelWidth(panel, clamped);
-        }
- 
-        // 状态恢复属于用户偏好:移除 autoCollapsed 标记(后续窗口适配会重新打标)
-        delete panel.dataset.autoCollapsed;
- 
-        // collapsed:只恢复用户手动折叠状态(StateManager 已保证是 boolean)
-        if (panelState.collapsed) {
-          panel.classList.add('collapsed');
-          this.updateCollapseButtonState(panelType, true);
-        } else {
-          panel.classList.remove('collapsed');
-          this.updateCollapseButtonState(panelType, false);
-        }
-      });
-    }
- 
-    /**
-     * 更新折叠按钮的图标与 ARIA(用于状态恢复,不触发保存/焦点移动)
-     * @param {'groups'|'accounts'|'emails'} panelType
-     * @param {boolean} collapsed
-     */
-    updateCollapseButtonState(panelType, collapsed) {
-      const panel = this.panels.get(panelType);
-      Iif (!panel) return;
- 
-      const btn = panel.querySelector('.collapse-btn');
-      Iif (!btn) return;
- 
-      if (collapsed) {
-        btn.setAttribute('aria-expanded', 'false');
-        btn.setAttribute('aria-label', `展开${this.getPanelDisplayName(panelType)}面板`);
-        btn.setAttribute('title', `展开${this.getPanelDisplayName(panelType)}面板`);
-        btn.textContent = '→';
-        return;
-      }
- 
-      btn.setAttribute('aria-expanded', 'true');
-      btn.setAttribute('aria-label', `折叠${this.getPanelDisplayName(panelType)}面板`);
-      btn.setAttribute('title', `折叠${this.getPanelDisplayName(panelType)}面板`);
-      btn.textContent = '←';
-    }
- 
-    /**
-     * 获取面板中文名称(用于 aria-label / 提示文案)
-     * @param {'groups'|'accounts'|'emails'} panelType
-     * @returns {string}
-     */
-    getPanelDisplayName(panelType) {
-      switch (panelType) {
-        case 'groups':
-          return '分组';
-        case 'accounts':
-          return '账号';
-        case 'emails':
-          return '邮件列表';
-        default:
-          return '面板';
-      }
-    }
- 
-    /**
-     * 折叠按钮点击处理
-     * @param {MouseEvent} e
-     */
-    onCollapseButtonClick(e) {
-      const btn = e && e.currentTarget ? e.currentTarget : null;
-      const panelType = btn && btn.dataset ? btn.dataset.panel : null;
-      if (!panelType) return;
- 
-      e.preventDefault();
-      this.togglePanel(panelType);
-    }
- 
-    /**
-     * 确保三侧边栏面板都存在折叠指示器(在 HTML 未提供时由 JS 动态创建)
-     * @returns {void}
-     */
-    ensureCollapsedIndicators() {
-      this.indicators.clear();
- 
-      ['groups', 'accounts', 'emails'].forEach((panelType) => {
-        const panel = this.panels.get(panelType);
-        Iif (!panel) return;
- 
-        let indicator = panel.querySelector(
-          `.collapsed-indicator[data-panel="${panelType}"]`
-        );
-        if (!indicator) {
-          indicator = document.createElement('div');
-          indicator.className = 'collapsed-indicator';
-          indicator.dataset.panel = panelType;
-          indicator.setAttribute('role', 'button');
-          indicator.setAttribute('tabindex', '0');
-          indicator.setAttribute(
-            'aria-label',
-            `展开${this.getPanelDisplayName(panelType)}面板`
-          );
-          indicator.setAttribute(
-            'title',
-            `展开${this.getPanelDisplayName(panelType)}面板`
-          );
- 
-          const text = document.createElement('span');
-          text.className = 'indicator-text';
-          text.textContent = this.getPanelDisplayName(panelType);
-          indicator.appendChild(text);
- 
-          panel.appendChild(indicator);
-        }
- 
-        indicator.removeEventListener('click', this._onIndicatorClick);
-        indicator.addEventListener('click', this._onIndicatorClick);
-        indicator.removeEventListener('keydown', this._onIndicatorKeydown);
-        indicator.addEventListener('keydown', this._onIndicatorKeydown);
- 
-        this.indicators.set(panelType, indicator);
-      });
-    }
- 
-    /**
-     * 折叠指示器点击:展开对应面板
-     * @param {MouseEvent} e
-     */
-    onIndicatorClick(e) {
-      const indicator = e && e.currentTarget ? e.currentTarget : null;
-      const panelType = indicator && indicator.dataset ? indicator.dataset.panel : null;
-      if (!panelType) return;
- 
-      e.preventDefault();
-      this.expandPanel(panelType);
-    }
- 
-    /**
-     * 折叠指示器键盘支持:Enter/Space 展开
-     * @param {KeyboardEvent} e
-     */
-    onIndicatorKeydown(e) {
-      if (!e) return;
-      const key = e.key;
-      if (key !== 'Enter' && key !== ' ') return;
- 
-      e.preventDefault();
-      this.onIndicatorClick({
-        currentTarget: e.currentTarget,
-        preventDefault: () => {}
-      });
-    }
- 
-    /**
-     * 更新折叠指示器偏移,避免多个折叠指示器在同一 x 坐标重叠
-     * @returns {void}
-     */
-    updateCollapsedIndicatorOffsets() {
-      const indicatorWidth = this.getCollapsedIndicatorWidth();
-      let offset = 0;
- 
-      ['groups', 'accounts', 'emails'].forEach((panelType) => {
-        const panel = this.panels.get(panelType);
-        const indicator = this.indicators.get(panelType);
-        Iif (!panel || !indicator) return;
- 
-        if (panel.classList.contains('collapsed')) {
-          indicator.style.setProperty('--collapsed-indicator-offset', `${offset}px`);
-          offset += indicatorWidth;
-        } else {
-          indicator.style.removeProperty('--collapsed-indicator-offset');
-        }
-      });
-    }
- 
-    /**
-     * 获取折叠指示器宽度(px)
-     * @returns {number}
-     */
-    getCollapsedIndicatorWidth() {
-      const raw = getComputedStyle(document.documentElement).getPropertyValue(
-        '--panel-collapsed-indicator-width'
-      );
-      const parsed = this.parsePx(raw);
-      return parsed !== null ? parsed : 48;
-    }
- 
-    /**
-     * 获取面板最小宽度(px)
-     * @param {'groups'|'accounts'|'emails'} panelType
-     * @returns {number}
-     */
-    getMinWidth(panelType) {
-      switch (panelType) {
-        case 'groups':
-          return 150;
-        case 'accounts':
-          return 180;
-        case 'emails':
-          return 280;
-        default:
-          return 150;
-      }
-    }
- 
-    /**
-     * 获取面板最大宽度(px)
-     * @param {'groups'|'accounts'|'emails'} panelType
-     * @returns {number}
-     */
-    getMaxWidth(panelType) {
-      switch (panelType) {
-        case 'groups':
-          return 400;
-        case 'accounts':
-          return 500;
-        case 'emails':
-          return 600;
-        default:
-          return 600;
-      }
-    }
- 
-    /**
-     * 更新面板宽度(通过 CSS 变量)
-     * @param {HTMLElement} panel 目标面板元素(必须包含 data-panel)
-     * @param {number} width 新宽度(px)
-     */
-    updatePanelWidth(panel, width) {
-      if (!panel || !panel.dataset || !panel.dataset.panel) return;
-      if (typeof width !== 'number' || Number.isNaN(width)) return;
- 
-      const panelType = panel.dataset.panel;
-      const cssVarName = PANEL_WIDTH_VAR_MAP[panelType];
-      if (!cssVarName) return;
- 
-      document.documentElement.style.setProperty(cssVarName, `${width}px`);
- 
-      // 如果面板当前是展开状态,同步更新“布局生效宽度”变量,保证 Grid 立即生效
-      const layoutWidthVar = PANEL_LAYOUT_WIDTH_VAR_MAP[panelType];
-      if (layoutWidthVar && !panel.classList.contains('collapsed')) {
-        document.documentElement.style.setProperty(layoutWidthVar, `${width}px`);
-      }
- 
-      // 同步更新 resizer 的 ARIA(便于后续键盘支持与可访问性测试)
-      const resizer = this.resizers.get(panelType);
-      Eif (resizer) {
-        resizer.setAttribute('aria-valuenow', String(Math.round(width)));
-      }
-    }
- 
-    /**
-     * 同步所有面板的“布局生效宽度/最小宽度”CSS 变量(TASK-03-003)
-     *
-     * 目的:
-     * - 折叠时 Grid 列宽应允许收缩到 0(因此 layout-min-width 需要变为 0)
-     * - 展开时 Grid 列宽应恢复为保存宽度(layout-width = saved width)
-     */
-    syncAllPanelsLayoutVars() {
-      ['groups', 'accounts', 'emails'].forEach((panelType) => {
-        const panel = this.panels.get(panelType);
-        Iif (!panel) return;
- 
-        if (panel.classList.contains('collapsed')) {
-          this.applyCollapsedLayoutVars(panelType);
-        } else {
-          this.applyExpandedLayoutVars(panelType);
-        }
-      });
-    }
- 
-    /**
-     * 获取“保存宽度”CSS 变量值(如 "200px")
-     * @param {'groups'|'accounts'|'emails'} panelType
-     * @returns {string|null}
-     */
-    getSavedWidthCssValue(panelType) {
-      const varName = PANEL_WIDTH_VAR_MAP[panelType];
-      Iif (!varName) return null;
- 
-      const raw = getComputedStyle(document.documentElement).getPropertyValue(varName);
-      const parsed = this.parsePx(raw);
-      Iif (parsed === null) return null;
-      return `${parsed}px`;
-    }
- 
-    /**
-     * 应用展开状态下的布局变量(layout-width/layout-min-width)
-     * @param {'groups'|'accounts'|'emails'} panelType
-     */
-    applyExpandedLayoutVars(panelType) {
-      const layoutWidthVar = PANEL_LAYOUT_WIDTH_VAR_MAP[panelType];
-      const layoutMinVar = PANEL_LAYOUT_MIN_WIDTH_VAR_MAP[panelType];
-      Iif (!layoutWidthVar || !layoutMinVar) return;
- 
-      const savedWidth = this.getSavedWidthCssValue(panelType);
-      Eif (savedWidth) {
-        document.documentElement.style.setProperty(layoutWidthVar, savedWidth);
-      }
-      document.documentElement.style.setProperty(
-        layoutMinVar,
-        `${this.getMinWidth(panelType)}px`
-      );
-    }
- 
-    /**
-     * 应用折叠状态下的布局变量(layout-width/layout-min-width)
-     * @param {'groups'|'accounts'|'emails'} panelType
-     */
-    applyCollapsedLayoutVars(panelType) {
-      const layoutWidthVar = PANEL_LAYOUT_WIDTH_VAR_MAP[panelType];
-      const layoutMinVar = PANEL_LAYOUT_MIN_WIDTH_VAR_MAP[panelType];
-      Iif (!layoutWidthVar || !layoutMinVar) return;
- 
-      document.documentElement.style.setProperty(layoutWidthVar, '0px');
-      document.documentElement.style.setProperty(layoutMinVar, '0px');
-    }
- 
-    /**
-     * 获取当前参与布局计算的面板宽度(px)
-     * @param {'groups'|'accounts'|'emails'} panelType
-     * @param {HTMLElement} panelElement
-     * @returns {number}
-     */
-    getPanelLayoutWidth(panelType, panelElement) {
-      if (
-        panelElement &&
-        panelElement.classList &&
-        (panelElement.classList.contains('collapsed') ||
-          panelElement.classList.contains('hidden'))
-      ) {
-        return 0;
-      }
- 
-      // 展开状态下,“保存宽度”就是当前生效宽度(layout-width 主要用于折叠时置 0)
-      return this.getPanelWidth(panelType, panelElement);
-    }
- 
-    /**
-     * 折叠指定面板
-     *
-     * 注意:折叠规则(至少保留一个面板展开)、自动折叠标记(data-autoCollapsed)、
-     * 指示条(collapsed-indicator)等在后续 TASK 中实现。
-     *
-     * @param {'groups'|'accounts'|'emails'} panelType
-     * @param {boolean} [auto=false] 预留参数(后续用于标记自动折叠)
-     */
-    collapsePanel(panelType, auto = false) {
-      const panel = this.panels.get(panelType);
-      if (!panel) return;
- 
-      // 折叠规则:至少保持一个侧边栏面板展开(仅限制用户手动折叠;自动折叠可例外)
-      if (!auto && !panel.classList.contains('collapsed')) {
-        const expandedPanels = this.getExpandedPanels();
-        if (expandedPanels.length <= 1 && expandedPanels[0] === panelType) {
-          this.notify('至少需要保持一个面板展开', 'warning');
-          return;
-        }
-      }
- 
-      // 标记自动折叠(用于窗口适配时的“仅恢复自动折叠”逻辑,TASK-03-007)
-      if (auto) {
-        panel.dataset.autoCollapsed = 'true';
-      } else {
-        delete panel.dataset.autoCollapsed;
-      }
- 
-      panel.classList.add('collapsed');
-      this.applyCollapsedLayoutVars(panelType);
-      this.updateCollapsedIndicatorOffsets();
- 
-      const btn = panel.querySelector('.collapse-btn');
-      Eif (btn) {
-        btn.setAttribute('aria-expanded', 'false');
-        btn.setAttribute(
-          'aria-label',
-          `展开${this.getPanelDisplayName(panelType)}面板`
-        );
-        btn.setAttribute(
-          'title',
-          `展开${this.getPanelDisplayName(panelType)}面板`
-        );
-        btn.textContent = '→';
-      }
- 
-      // 焦点管理:用户手动折叠后,将焦点移到指示器(自动折叠不抢焦点)
-      if (!auto) {
-        const indicator = this.indicators.get(panelType);
-        Eif (indicator && typeof indicator.focus === 'function') {
-          indicator.focus();
-        }
-      }
- 
-      // 折叠后立即保存(自动折叠不保存,TASK-04-002 / FD F5.2)
-      if (!auto) {
-        this.saveStateNow();
-      }
-    }
- 
-    /**
-     * 展开指定面板
-     * @param {'groups'|'accounts'|'emails'} panelType
-     */
-    expandPanel(panelType, auto = false) {
-      const panel = this.panels.get(panelType);
-      if (!panel) return;
- 
-      // 展开时移除自动折叠标记(TASK-03-007)
-      delete panel.dataset.autoCollapsed;
- 
-      panel.classList.remove('collapsed');
-      this.applyExpandedLayoutVars(panelType);
-      this.updateCollapsedIndicatorOffsets();
- 
-      const btn = panel.querySelector('.collapse-btn');
-      Eif (btn) {
-        btn.setAttribute('aria-expanded', 'true');
-        btn.setAttribute(
-          'aria-label',
-          `折叠${this.getPanelDisplayName(panelType)}面板`
-        );
-        btn.setAttribute(
-          'title',
-          `折叠${this.getPanelDisplayName(panelType)}面板`
-        );
-        btn.textContent = '←';
-        // 展开后焦点回到折叠按钮(便于键盘连续操作);自动展开不抢焦点
-        if (!auto && typeof btn.focus === 'function') {
-          btn.focus();
-        }
-      }
- 
-      // 自动展开属于响应式行为,不应持久化;手动展开才保存(TASK-04-002)
-      if (!auto) {
-        this.saveStateNow();
-      }
-    }
- 
-    /**
-     * 获取当前处于展开状态的侧边栏面板列表
-     * @returns {Array<'groups'|'accounts'|'emails'>}
-     */
-    getExpandedPanels() {
-      /** @type {Array<'groups'|'accounts'|'emails'>} */
-      const expanded = [];
-      ['groups', 'accounts', 'emails'].forEach((panelType) => {
-        const panel = this.panels.get(panelType);
-        Iif (!panel) return;
-        if (!panel.classList.contains('collapsed')) {
-          expanded.push(panelType);
-        }
-      });
-      return expanded;
-    }
- 
-    /**
-     * 显示提示信息(优先使用主应用内置的 showToast;不可用时降级为 alert)
-     * @param {string} message
-     * @param {'info'|'success'|'warning'|'error'} [type='warning']
-     */
-    notify(message, type = 'warning') {
-      try {
-        Eif (typeof window !== 'undefined' && typeof window.showToast === 'function') {
-          window.showToast(message, type);
-          return;
-        }
-      } catch (_) {
-        // ignore
-      }
- 
-      try {
-        if (typeof window !== 'undefined' && typeof window.alert === 'function') {
-          window.alert(message);
-        }
-      } catch (_) {
-        // ignore
-      }
-    }
- 
-    /**
-     * 切换面板折叠状态
-     * @param {'groups'|'accounts'|'emails'} panelType
-     */
-    togglePanel(panelType) {
-      const panel = this.panels.get(panelType);
-      if (!panel) return;
- 
-      if (panel.classList.contains('collapsed')) {
-        this.expandPanel(panelType);
-      } else {
-        this.collapsePanel(panelType, false);
-      }
-    }
- 
-    /**
-     * window.resize 事件处理(防抖 200ms)
-     */
-    onWindowResize() {
-      Iif (!this.windowResizeDebouncer) {
-        this.handleWindowResize();
-        return;
-      }
-      this.windowResizeDebouncer.debounced();
-    }
- 
-    /**
-     * 根据断点自动折叠/恢复面板(F5)
-     *
-     * 规则:
-     * - <1200:自动折叠 groups
-     * - <900:自动折叠 accounts
-     * - <700:自动折叠 emails
-     * - 仅恢复“自动折叠”的面板(data-autoCollapsed="true"),不恢复用户手动折叠
-     */
-    handleWindowResize() {
-      Iif (typeof window === 'undefined' || typeof window.innerWidth !== 'number') return;
- 
-      const width = window.innerWidth;
-      Iif (!Number.isFinite(width) || width <= 0) return;
- 
-      const shouldCollapseGroups = width < 1200;
-      const shouldCollapseAccounts = width < 900;
-      const shouldCollapseEmails = width < 700;
- 
-      if (shouldCollapseGroups) {
-        this.autoCollapsePanelIfNeeded('groups');
-      } else {
-        this.restorePanelIfNeeded('groups');
-      }
- 
-      if (shouldCollapseAccounts) {
-        this.autoCollapsePanelIfNeeded('accounts');
-      } else {
-        this.restorePanelIfNeeded('accounts');
-      }
- 
-      if (shouldCollapseEmails) {
-        this.autoCollapsePanelIfNeeded('emails');
-      } else {
-        this.restorePanelIfNeeded('emails');
-      }
-    }
- 
-    /**
-     * 自动折叠面板(不保存;且不覆盖用户手动折叠标记)
-     * @param {'groups'|'accounts'|'emails'} panelType
-     */
-    autoCollapsePanelIfNeeded(panelType) {
-      const panel = this.panels.get(panelType);
-      Iif (!panel) return;
- 
-      // emailListPanel 可能被业务逻辑临时隐藏(display:none);此时不参与断点折叠/恢复
-      if (panel.classList && panel.classList.contains('hidden')) return;
- 
-      // 已折叠(可能是用户手动折叠),不要改写为 autoCollapsed
-      if (panel.classList && panel.classList.contains('collapsed')) return;
- 
-      this.collapsePanel(panelType, true);
-    }
- 
-    /**
-     * 恢复面板(仅恢复自动折叠的;不保存)
-     * @param {'groups'|'accounts'|'emails'} panelType
-     */
-    restorePanelIfNeeded(panelType) {
-      const panel = this.panels.get(panelType);
-      Iif (!panel) return;
- 
-      if (panel.classList && panel.classList.contains('hidden')) return;
- 
-      const isAutoCollapsed = panel.dataset && panel.dataset.autoCollapsed === 'true';
-      if (!isAutoCollapsed) return;
- 
-      Eif (panel.classList && panel.classList.contains('collapsed')) {
-        this.expandPanel(panelType, true);
-      }
-    }
- 
-    /**
-     * Resizer 键盘支持:方向键调整宽度(F6.1)
-     * @param {KeyboardEvent} e
-     */
-    onResizerKeydown(e) {
-      Iif (!e) return;
- 
-      const key = e.key;
-      Iif (key !== 'ArrowLeft' && key !== 'ArrowRight') return;
- 
-      const resizer = e.currentTarget;
-      const panelType = resizer && resizer.dataset ? resizer.dataset.resizer : null;
-      Iif (!panelType) return;
- 
-      const panel = this.panels.get(panelType);
-      Iif (!panel) return;
-      Iif (panel.classList && panel.classList.contains('collapsed')) return;
- 
-      const step = e.shiftKey ? 50 : 10;
-      const delta = key === 'ArrowLeft' ? -step : step;
- 
-      e.preventDefault();
- 
-      const startWidth = this.getPanelLayoutWidth(panelType, panel);
-      const nextWidth = this.calculateNewWidth(0, delta, startWidth, panelType);
-      this.updatePanelWidth(panel, nextWidth);
- 
-      // 键盘调整也应触发防抖保存(F6.1)
-      Eif (this.saveDebouncer && this.stateManager) {
-        this.saveDebouncer.debounced();
-      }
-    }
- 
-    /**
-     * mousedown:开始拖动调整
-     * @param {MouseEvent} e
-     */
-    startResize(e) {
-      // 仅响应鼠标左键
-      if (e && typeof e.button === 'number' && e.button !== 0) return;
- 
-      const resizer = e.currentTarget;
-      const panelType = resizer && resizer.dataset ? resizer.dataset.resizer : null;
-      if (!panelType) return;
- 
-      const panel = this.panels.get(panelType);
-      if (!panel) return;
-      // 折叠状态下禁止拖动(与 F2.5/F2.6 一致)
-      if (panel.classList.contains('collapsed')) return;
- 
-      e.preventDefault();
- 
-      // 视觉反馈:全局光标 + 禁止选中文本
-      Eif (document.body) {
-        document.body.classList.add('layout-resizing');
-      }
- 
-      this.isResizing = true;
-      this.currentPanelType = panelType;
-      this.currentPanel = panel;
-      this.currentResizer = resizer;
-      Eif (this.currentResizer && this.currentResizer.classList) {
-        this.currentResizer.classList.add('active');
-      }
-      this.startX = e.clientX;
-      this.pendingClientX = e.clientX;
-      this.startWidth = this.getPanelLayoutWidth(panelType, panel);
- 
-      document.addEventListener('mousemove', this._onResize);
-      document.addEventListener('mouseup', this._onStopResize);
-      // 边界情况:鼠标移出窗口/窗口失焦时,确保拖动能正常结束(避免卡死在 resizing 状态)
-      window.addEventListener('blur', this._onStopResize);
-      document.addEventListener('mouseleave', this._onStopResize);
-    }
- 
-    /**
-     * mousemove:拖动中(使用 requestAnimationFrame 做节流)
-     * @param {MouseEvent} e
-     */
-    resize(e) {
-      if (!this.isResizing || !this.currentPanel || !this.currentPanelType) return;
- 
-      this.pendingClientX = e.clientX;
- 
-      if (this.rafId) return;
-      this.rafId = requestAnimationFrame(() => {
-        this.rafId = 0;
-        Iif (!this.isResizing || !this.currentPanel || !this.currentPanelType) return;
- 
-        try {
-          const newWidth = this.calculateNewWidth(
-            this.startX,
-            this.pendingClientX,
-            this.startWidth,
-            this.currentPanelType
-          );
-          this.updatePanelWidth(this.currentPanel, newWidth);
-        } catch (err) {
-          // 边界保护:任何异常都不应让页面卡在拖动状态
-          try {
-            // eslint-disable-next-line no-console
-            console.error('[LayoutManager] resize failed:', err);
-          } catch (_) {
-            // ignore
-          }
-          this.stopResize();
-        }
-      });
-    }
- 
-    /**
-     * mouseup:结束拖动
-     */
-    stopResize() {
-      if (!this.isResizing) return;
- 
-      // 如果在 mouseup 前还有一帧待执行,先同步刷新最终宽度,避免快速拖动时丢帧
-      if (this.rafId && this.currentPanel && this.currentPanelType) {
-        cancelAnimationFrame(this.rafId);
-        this.rafId = 0;
- 
-        try {
-          const finalWidth = this.calculateNewWidth(
-            this.startX,
-            this.pendingClientX,
-            this.startWidth,
-            this.currentPanelType
-          );
-          this.updatePanelWidth(this.currentPanel, finalWidth);
-        } catch (err) {
-          try {
-            // eslint-disable-next-line no-console
-            console.error('[LayoutManager] stopResize flush failed:', err);
-          } catch (_) {
-            // ignore
-          }
-        }
-      }
- 
-      this.isResizing = false;
-      this.currentPanel = null;
-      this.currentPanelType = null;
- 
-      document.removeEventListener('mousemove', this._onResize);
-      document.removeEventListener('mouseup', this._onStopResize);
-      window.removeEventListener('blur', this._onStopResize);
-      document.removeEventListener('mouseleave', this._onStopResize);
- 
-      // 清理视觉反馈
-      Eif (this.currentResizer && this.currentResizer.classList) {
-        this.currentResizer.classList.remove('active');
-      }
-      this.currentResizer = null;
-      Eif (document.body) {
-        document.body.classList.remove('layout-resizing');
-      }
- 
-      // 拖动结束后 500ms 防抖保存(TASK-04-002)
-      Eif (this.saveDebouncer && this.stateManager) {
-        this.saveDebouncer.debounced();
-      }
-    }
- 
-    /**
-     * 计算拖动后的新宽度(仅处理最小/最大约束;邮件详情 400px 约束在 TASK-02-003 实现)
-     * @param {number} startX
-     * @param {number} currentX
-     * @param {number} startWidth
-     * @param {'groups'|'accounts'|'emails'} panelType
-     * @returns {number}
-     */
-    calculateNewWidth(startX, currentX, startWidth, panelType) {
-      const delta = currentX - startX;
-      let nextWidth = startWidth + delta;
- 
-      const minWidth = this.getMinWidth(panelType);
-      const maxWidth = this.getMaxWidth(panelType);
- 
-      // 先按面板自身的最小/最大值裁剪
-      nextWidth = Math.max(minWidth, Math.min(maxWidth, nextWidth));
- 
-      // 再保证邮件详情面板最小可见宽度(避免侧边栏把详情挤到不可读)
-      const containerWidth = this.getContainerWidth();
-      const detailMinWidth = this.getEmailDetailMinWidth();
-      Eif (containerWidth > 0 && detailMinWidth > 0) {
-        const otherPanelsWidth = this.calculateOtherPanelsWidth(panelType);
-        const maxAllowedByDetail = containerWidth - otherPanelsWidth - detailMinWidth;
-        Eif (Number.isFinite(maxAllowedByDetail)) {
-          nextWidth = Math.min(nextWidth, maxAllowedByDetail);
-          nextWidth = Math.max(minWidth, nextWidth);
-        }
-      }
-      return nextWidth;
-    }
- 
-    /**
-     * 计算除当前面板外的其他侧边栏面板总宽度(px)
-     * @param {'groups'|'accounts'|'emails'} excludePanelType
-     * @returns {number}
-     */
-    calculateOtherPanelsWidth(excludePanelType) {
-      let total = 0;
-      ['groups', 'accounts', 'emails'].forEach((panelType) => {
-        if (panelType === excludePanelType) return;
-        const panel = this.panels.get(panelType);
-        total += this.getPanelLayoutWidth(panelType, panel);
-      });
-      return total;
-    }
- 
-    /**
-     * 获取容器可用宽度(优先取 .main-container 的实际宽度;无布局时回退到 window.innerWidth)
-     * @returns {number}
-     */
-    getContainerWidth() {
-      if (this.container) {
-        const rect = this.container.getBoundingClientRect
-          ? this.container.getBoundingClientRect()
-          : null;
-        if (rect && typeof rect.width === 'number' && rect.width > 0) {
-          return rect.width;
-        }
-      }
-      if (typeof window !== 'undefined' && typeof window.innerWidth === 'number') {
-        return window.innerWidth;
-      }
-      return 0;
-    }
- 
-    /**
-     * 获取邮件详情面板最小宽度(从 CSS 变量读取;缺失时默认 400)
-     * @returns {number}
-     */
-    getEmailDetailMinWidth() {
-      const raw = getComputedStyle(document.documentElement).getPropertyValue(
-        '--email-detail-panel-min-width'
-      );
-      const parsed = this.parsePx(raw);
-      return parsed !== null ? parsed : 400;
-    }
- 
-    /**
-     * 获取当前面板宽度(优先读取 CSS 变量,便于在 jsdom 中测试)
-     * @param {'groups'|'accounts'|'emails'} panelType
-     * @param {HTMLElement} panelElement
-     * @returns {number}
-     */
-    getPanelWidth(panelType, panelElement) {
-      const cssVarName = PANEL_WIDTH_VAR_MAP[panelType];
-      const raw =
-        (cssVarName &&
-          getComputedStyle(document.documentElement).getPropertyValue(cssVarName)) ||
-        '';
-      const parsed = this.parsePx(raw);
-      if (parsed !== null) return parsed;
- 
-      if (panelElement) {
-        const rect = panelElement.getBoundingClientRect
-          ? panelElement.getBoundingClientRect()
-          : null;
-        if (rect && typeof rect.width === 'number' && rect.width > 0) {
-          return rect.width;
-        }
-        Eif (typeof panelElement.offsetWidth === 'number' && panelElement.offsetWidth > 0) {
-          return panelElement.offsetWidth;
-        }
-      }
- 
-      return 0;
-    }
- 
-    /**
-     * 解析 "123px" 为 number;无法解析时返回 null
-     * @param {string} value
-     * @returns {number|null}
-     */
-    parsePx(value) {
-      if (!value || typeof value !== 'string') return null;
-      const trimmed = value.trim();
-      if (!trimmed.endsWith('px')) return null;
-      const num = Number.parseFloat(trimmed.slice(0, -2));
-      return Number.isFinite(num) ? num : null;
-    }
-  }
- 
-  // 浏览器环境挂载
-  global.LayoutManager = LayoutManager;
- 
-  // Jest/Node 环境导出
-  Eif (typeof module !== 'undefined' && module.exports) {
-    module.exports = LayoutManager;
-  }
-})(typeof window !== 'undefined' ? window : globalThis);
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/tests/layout-system/coverage/lcov-report/prettify.css b/tests/layout-system/coverage/lcov-report/prettify.css deleted file mode 100644 index b317a7c..0000000 --- a/tests/layout-system/coverage/lcov-report/prettify.css +++ /dev/null @@ -1 +0,0 @@ -.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} diff --git a/tests/layout-system/coverage/lcov-report/prettify.js b/tests/layout-system/coverage/lcov-report/prettify.js deleted file mode 100644 index b322523..0000000 --- a/tests/layout-system/coverage/lcov-report/prettify.js +++ /dev/null @@ -1,2 +0,0 @@ -/* 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;V122)){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;arat[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=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/tests/layout-system/coverage/lcov-report/sort-arrow-sprite.png b/tests/layout-system/coverage/lcov-report/sort-arrow-sprite.png deleted file mode 100644 index 6ed68316eb3f65dec9063332d2f69bf3093bbfab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^>_9Bd!3HEZxJ@+%Qh}Z>jv*C{$p!i!8j}?a+@3A= zIAGwzjijN=FBi!|L1t?LM;Q;gkwn>2cAy-KV{dn nf0J1DIvEHQu*n~6U}x}qyky7vi4|9XhBJ7&`njxgN@xNA8m%nc diff --git a/tests/layout-system/coverage/lcov-report/sorter.js b/tests/layout-system/coverage/lcov-report/sorter.js deleted file mode 100644 index 4ed70ae..0000000 --- a/tests/layout-system/coverage/lcov-report/sorter.js +++ /dev/null @@ -1,210 +0,0 @@ -/* 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 + ''; - } - } - 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); diff --git a/tests/layout-system/coverage/lcov-report/state-manager.js.html b/tests/layout-system/coverage/lcov-report/state-manager.js.html deleted file mode 100644 index a1e5293..0000000 --- a/tests/layout-system/coverage/lcov-report/state-manager.js.html +++ /dev/null @@ -1,1045 +0,0 @@ - - - - - - Code coverage report for state-manager.js - - - - - - - - - -
-
-

All files state-manager.js

-
- -
- 91.17% - Statements - 124/136 -
- - -
- 85.55% - Branches - 77/90 -
- - -
- 100% - Functions - 13/13 -
- - -
- 94.87% - Lines - 111/117 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 -213 -214 -215 -216 -217 -218 -219 -220 -221 -222 -223 -224 -225 -226 -227 -228 -229 -230 -231 -232 -233 -234 -235 -236 -237 -238 -239 -240 -241 -242 -243 -244 -245 -246 -247 -248 -249 -250 -251 -252 -253 -254 -255 -256 -257 -258 -259 -260 -261 -262 -263 -264 -265 -266 -267 -268 -269 -270 -271 -272 -273 -274 -275 -276 -277 -278 -279 -280 -281 -282 -283 -284 -285 -286 -287 -288 -289 -290 -291 -292 -293 -294 -295 -296 -297 -298 -299 -300 -301 -302 -303 -304 -305 -306 -307 -308 -309 -310 -311 -312 -313 -314 -315 -316 -317 -318 -319 -320 -321  -  -  -  -  -  -  -  -  -  -  -  -  -13x -  -  -13x -13x -  -  -  -  -  -  -  -360x -360x -  -360x -  -1x -  -  -  -  -  -  -  -  -  -365x -365x -  -  -  -  -  -  -  -  -  -150x -150x -1x -  -1x -  -  -  -1x -  -  -149x -  -  -  -  -  -  -149x -1x -  -1x -  -  -  -1x -  -  -148x -148x -  -148x -148x -146x -  -  -2x -1x -  -1x -  -  -  -1x -1x -1x -1x -  -  -  -  -  -  -  -  -  -  -  -  -1x -  -1x -  -  -  -1x -  -  -  -  -  -  -  -  -  -203x -203x -  -  -  -  -  -  -  -  -  -203x -  -203x -203x -203x -  -3x -  -3x -  -  -  -3x -  -  -200x -  -106x -106x -106x -  -  -2x -2x -  -  -  -2x -  -2x -  -  -  -2x -  -  -104x -104x -1x -1x -  -  -  -1x -  -1x -  -  -  -1x -  -  -103x -  -  -  -  -  -  -  -  -6x -6x -6x -6x -6x -  -  -  -  -  -  -  -  -  -  -  -8x -7x -7x -5x -4x -  -4x -  -  -  -  -  -  -  -  -1x -1x -  -1x -1x -1x -4x -4x -4x -3x -2x -  -  -  -  -  -  -1x -2x -2x -2x -2x -2x -1x -  -  -1x -  -2x -  -  -1x -  -1x -2x -2x -  -  -  -  -  -  -  -  -  -  -  -  -1258x -1257x -  -1256x -1256x -3760x -3760x -3760x -3757x -  -  -1252x -  -  -  -  -  -  -  -  -106x -  -  -106x -1x -  -  -  -105x -  -  -1x -  -  -  -  -13x -  -  -13x -13x -  -  - 
/**
- * 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 {
-        Iif (!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();
-      Iif (!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();
-        Iif (!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();
-      Iif (!storage) return;
- 
-      const keys = [];
-      try {
-        for (let i = 0; i < storage.length; i += 1) {
-          const k = storage.key(i);
-          Iif (!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);
-          Eif (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];
-        Iif (!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) {
-      Iif (!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 环境导出
-  Eif (typeof module !== 'undefined' && module.exports) {
-    module.exports = StateManager;
-  }
-})(typeof window !== 'undefined' ? window : globalThis);
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/tests/layout-system/coverage/lcov.info b/tests/layout-system/coverage/lcov.info deleted file mode 100644 index 9359e08..0000000 --- a/tests/layout-system/coverage/lcov.info +++ /dev/null @@ -1,1377 +0,0 @@ -TN: -SF:static\js\layout-manager.js -FN:17,(anonymous_0) -FN:62,(anonymous_1) -FN:119,(anonymous_2) -FN:121,(anonymous_3) -FN:128,(anonymous_4) -FN:135,(anonymous_5) -FN:141,(anonymous_6) -FN:146,(anonymous_7) -FN:156,(anonymous_8) -FN:193,(anonymous_9) -FN:204,(anonymous_10) -FN:235,(anonymous_11) -FN:239,(anonymous_12) -FN:241,(anonymous_13) -FN:246,(anonymous_14) -FN:253,(anonymous_15) -FN:267,(anonymous_16) -FN:269,(anonymous_17) -FN:315,(anonymous_18) -FN:317,(anonymous_19) -FN:362,(anonymous_20) -FN:374,(anonymous_21) -FN:390,(anonymous_22) -FN:397,(anonymous_23) -FN:412,(anonymous_24) -FN:426,(anonymous_25) -FN:446,(anonymous_26) -FN:461,(anonymous_27) -FN:481,(anonymous_28) -FN:498,(anonymous_29) -FN:499,(anonymous_30) -FN:526,(anonymous_31) -FN:530,(anonymous_32) -FN:563,(anonymous_33) -FN:589,(anonymous_34) -FN:606,(anonymous_35) -FN:619,(anonymous_36) -FN:622,(anonymous_37) -FN:665,(anonymous_38) -FN:678,(anonymous_39) -FN:686,(anonymous_40) -FN:694,(anonymous_41) -FN:698,(anonymous_42) -FN:716,(anonymous_43) -FN:729,(anonymous_44) -FN:747,(anonymous_45) -FN:765,(anonymous_46) -FN:795,(anonymous_47) -FN:796,(anonymous_48) -FN:813,(anonymous_49) -FN:827,(anonymous_50) -FN:846,(anonymous_51) -FN:861,(anonymous_52) -FN:884,(anonymous_53) -FN:940,(anonymous_54) -FN:979,(anonymous_55) -FN:982,(anonymous_56) -FN:997,(anonymous_57) -FN:1020,(anonymous_58) -FN:1034,(anonymous_59) -FN:1051,(anonymous_60) -FN:1084,(anonymous_61) -FN:1101,(anonymous_62) -FN:1119,(anonymous_63) -FN:1152,(anonymous_64) -FN:1194,(anonymous_65) -FN:1200,(anonymous_66) -FN:1228,(anonymous_67) -FN:1286,(anonymous_68) -FN:1315,(anonymous_69) -FN:1317,(anonymous_70) -FN:1329,(anonymous_71) -FN:1348,(anonymous_72) -FN:1362,(anonymous_73) -FN:1391,(anonymous_74) -FNF:75 -FNH:75 -FNDA:12,(anonymous_0) -FNDA:97,(anonymous_1) -FNDA:40,(anonymous_2) -FNDA:9,(anonymous_3) -FNDA:97,(anonymous_4) -FNDA:291,(anonymous_5) -FNDA:291,(anonymous_6) -FNDA:291,(anonymous_7) -FNDA:291,(anonymous_8) -FNDA:97,(anonymous_9) -FNDA:97,(anonymous_10) -FNDA:194,(anonymous_11) -FNDA:31,(anonymous_12) -FNDA:13,(anonymous_13) -FNDA:36,(anonymous_14) -FNDA:2,(anonymous_15) -FNDA:43,(anonymous_16) -FNDA:129,(anonymous_17) -FNDA:142,(anonymous_18) -FNDA:568,(anonymous_19) -FNDA:43,(anonymous_20) -FNDA:36,(anonymous_21) -FNDA:97,(anonymous_22) -FNDA:1,(anonymous_23) -FNDA:98,(anonymous_24) -FNDA:67,(anonymous_25) -FNDA:2,(anonymous_26) -FNDA:1,(anonymous_27) -FNDA:2,(anonymous_28) -FNDA:2,(anonymous_29) -FNDA:6,(anonymous_30) -FNDA:1,(anonymous_31) -FNDA:3,(anonymous_32) -FNDA:9,(anonymous_33) -FNDA:1024,(anonymous_34) -FNDA:9,(anonymous_35) -FNDA:98,(anonymous_36) -FNDA:294,(anonymous_37) -FNDA:5,(anonymous_38) -FNDA:3,(anonymous_39) -FNDA:1,(anonymous_40) -FNDA:165,(anonymous_41) -FNDA:495,(anonymous_42) -FNDA:165,(anonymous_43) -FNDA:374,(anonymous_44) -FNDA:59,(anonymous_45) -FNDA:68,(anonymous_46) -FNDA:100,(anonymous_47) -FNDA:300,(anonymous_48) -FNDA:315,(anonymous_49) -FNDA:315,(anonymous_50) -FNDA:50,(anonymous_51) -FNDA:132,(anonymous_52) -FNDA:52,(anonymous_53) -FNDA:19,(anonymous_54) -FNDA:27,(anonymous_55) -FNDA:81,(anonymous_56) -FNDA:4,(anonymous_57) -FNDA:15,(anonymous_58) -FNDA:9,(anonymous_59) -FNDA:106,(anonymous_60) -FNDA:13,(anonymous_61) -FNDA:305,(anonymous_62) -FNDA:9,(anonymous_63) -FNDA:17,(anonymous_64) -FNDA:50,(anonymous_65) -FNDA:30,(anonymous_66) -FNDA:13,(anonymous_67) -FNDA:52,(anonymous_68) -FNDA:55,(anonymous_69) -FNDA:165,(anonymous_70) -FNDA:54,(anonymous_71) -FNDA:52,(anonymous_72) -FNDA:134,(anonymous_73) -FNDA:804,(anonymous_74) -DA:17,12 -DA:21,12 -DA:23,12 -DA:29,12 -DA:43,12 -DA:52,12 -DA:64,97 -DA:67,97 -DA:70,97 -DA:73,97 -DA:76,97 -DA:79,97 -DA:82,97 -DA:85,97 -DA:88,97 -DA:91,97 -DA:94,97 -DA:97,97 -DA:100,97 -DA:103,97 -DA:106,97 -DA:107,97 -DA:108,97 -DA:109,97 -DA:110,97 -DA:111,97 -DA:112,97 -DA:113,97 -DA:114,97 -DA:117,97 -DA:119,97 -DA:121,97 -DA:129,97 -DA:132,97 -DA:133,97 -DA:136,291 -DA:140,97 -DA:141,97 -DA:142,291 -DA:146,97 -DA:147,291 -DA:148,291 -DA:151,291 -DA:152,291 -DA:156,97 -DA:157,291 -DA:158,291 -DA:162,97 -DA:165,97 -DA:166,97 -DA:168,96 -DA:171,96 -DA:175,97 -DA:176,97 -DA:177,85 -DA:179,97 -DA:180,97 -DA:184,97 -DA:185,97 -DA:186,85 -DA:188,97 -DA:189,97 -DA:193,97 -DA:194,97 -DA:197,97 -DA:205,97 -DA:206,97 -DA:207,86 -DA:211,11 -DA:213,11 -DA:214,11 -DA:215,11 -DA:219,0 -DA:221,0 -DA:226,0 -DA:236,194 -DA:238,194 -DA:240,31 -DA:241,31 -DA:242,13 -DA:243,13 -DA:247,36 -DA:248,1 -DA:249,1 -DA:251,36 -DA:254,2 -DA:255,1 -DA:256,1 -DA:268,43 -DA:269,43 -DA:270,129 -DA:273,129 -DA:275,129 -DA:278,129 -DA:281,129 -DA:282,129 -DA:286,129 -DA:290,129 -DA:293,43 -DA:316,142 -DA:317,142 -DA:318,568 -DA:319,3 -DA:320,3 -DA:324,142 -DA:326,142 -DA:327,142 -DA:328,142 -DA:331,142 -DA:334,142 -DA:335,142 -DA:339,142 -DA:342,142 -DA:343,142 -DA:344,142 -DA:348,142 -DA:349,142 -DA:350,142 -DA:355,139 -DA:363,43 -DA:365,43 -DA:366,43 -DA:367,43 -DA:375,36 -DA:378,36 -DA:391,97 -DA:393,97 -DA:394,97 -DA:395,97 -DA:397,1 -DA:398,1 -DA:401,1 -DA:402,1 -DA:405,1 -DA:414,98 -DA:417,98 -DA:418,0 -DA:419,0 -DA:422,98 -DA:423,98 -DA:424,98 -DA:426,98 -DA:427,67 -DA:428,67 -DA:434,98 -DA:435,98 -DA:437,0 -DA:448,2 -DA:451,2 -DA:452,0 -DA:453,0 -DA:456,2 -DA:457,2 -DA:458,2 -DA:461,2 -DA:462,1 -DA:463,1 -DA:483,2 -DA:484,2 -DA:487,2 -DA:488,2 -DA:489,2 -DA:490,2 -DA:496,2 -DA:498,2 -DA:499,2 -DA:500,6 -DA:501,6 -DA:504,6 -DA:505,6 -DA:508,6 -DA:511,6 -DA:515,2 -DA:516,2 -DA:519,2 -DA:527,1 -DA:528,1 -DA:530,1 -DA:531,3 -DA:532,3 -DA:533,3 -DA:536,3 -DA:537,3 -DA:538,3 -DA:539,3 -DA:540,3 -DA:541,3 -DA:545,3 -DA:548,3 -DA:549,1 -DA:550,1 -DA:552,2 -DA:553,2 -DA:564,9 -DA:565,9 -DA:567,9 -DA:568,9 -DA:570,9 -DA:571,1 -DA:572,1 -DA:573,1 -DA:574,1 -DA:575,1 -DA:578,8 -DA:579,8 -DA:580,8 -DA:581,8 -DA:590,1024 -DA:592,389 -DA:594,324 -DA:596,310 -DA:598,1 -DA:607,9 -DA:608,9 -DA:609,9 -DA:611,7 -DA:612,7 -DA:620,98 -DA:622,98 -DA:623,294 -DA:624,294 -DA:626,294 -DA:629,294 -DA:630,291 -DA:631,291 -DA:632,291 -DA:633,291 -DA:634,291 -DA:635,291 -DA:639,291 -DA:644,291 -DA:645,291 -DA:646,291 -DA:647,291 -DA:649,291 -DA:652,294 -DA:653,294 -DA:654,294 -DA:655,294 -DA:657,294 -DA:666,5 -DA:667,5 -DA:668,5 -DA:670,3 -DA:671,3 -DA:679,3 -DA:680,2 -DA:681,2 -DA:683,1 -DA:684,1 -DA:695,165 -DA:696,165 -DA:698,165 -DA:699,495 -DA:700,495 -DA:701,495 -DA:703,495 -DA:704,69 -DA:705,69 -DA:707,426 -DA:717,165 -DA:720,165 -DA:721,165 -DA:730,374 -DA:732,165 -DA:734,105 -DA:736,103 -DA:738,1 -DA:748,59 -DA:750,54 -DA:752,2 -DA:754,2 -DA:756,1 -DA:766,68 -DA:767,63 -DA:769,62 -DA:770,62 -DA:771,62 -DA:773,61 -DA:776,61 -DA:777,61 -DA:778,60 -DA:782,61 -DA:783,61 -DA:784,61 -DA:796,100 -DA:797,300 -DA:798,300 -DA:800,300 -DA:801,2 -DA:803,298 -DA:814,315 -DA:815,315 -DA:817,315 -DA:818,315 -DA:819,315 -DA:820,315 -DA:828,315 -DA:829,315 -DA:830,315 -DA:832,315 -DA:833,315 -DA:834,315 -DA:836,315 -DA:847,50 -DA:848,50 -DA:849,50 -DA:851,50 -DA:852,50 -DA:862,132 -DA:868,1 -DA:872,131 -DA:885,52 -DA:886,52 -DA:889,50 -DA:890,24 -DA:891,24 -DA:892,2 -DA:893,2 -DA:898,48 -DA:899,26 -DA:901,22 -DA:904,48 -DA:905,48 -DA:906,48 -DA:908,48 -DA:909,48 -DA:910,48 -DA:911,48 -DA:915,48 -DA:919,48 -DA:923,48 -DA:924,22 -DA:925,22 -DA:926,22 -DA:931,48 -DA:932,22 -DA:941,19 -DA:942,19 -DA:945,17 -DA:947,17 -DA:948,17 -DA:949,17 -DA:951,17 -DA:952,17 -DA:953,17 -DA:954,17 -DA:958,17 -DA:962,17 -DA:964,17 -DA:965,12 -DA:970,17 -DA:971,12 -DA:981,27 -DA:982,27 -DA:983,81 -DA:984,81 -DA:985,81 -DA:986,74 -DA:989,27 -DA:998,4 -DA:999,4 -DA:1000,4 -DA:1001,4 -DA:1007,0 -DA:1008,0 -DA:1009,0 -DA:1021,15 -DA:1022,15 -DA:1024,13 -DA:1025,3 -DA:1027,10 -DA:1035,9 -DA:1036,0 -DA:1037,0 -DA:1039,9 -DA:1052,106 -DA:1054,106 -DA:1055,106 -DA:1057,106 -DA:1058,106 -DA:1059,106 -DA:1061,106 -DA:1062,6 -DA:1064,100 -DA:1067,106 -DA:1068,4 -DA:1070,102 -DA:1073,106 -DA:1074,3 -DA:1076,103 -DA:1085,13 -DA:1086,13 -DA:1089,13 -DA:1092,12 -DA:1094,11 -DA:1102,305 -DA:1103,305 -DA:1105,305 -DA:1107,304 -DA:1108,304 -DA:1110,5 -DA:1111,5 -DA:1120,9 -DA:1122,9 -DA:1123,9 -DA:1125,9 -DA:1126,9 -DA:1127,9 -DA:1129,9 -DA:1130,9 -DA:1131,9 -DA:1133,9 -DA:1134,9 -DA:1136,9 -DA:1138,9 -DA:1139,9 -DA:1140,9 -DA:1143,9 -DA:1144,9 -DA:1154,17 -DA:1156,16 -DA:1157,16 -DA:1158,16 -DA:1160,15 -DA:1161,15 -DA:1163,14 -DA:1165,12 -DA:1168,12 -DA:1169,12 -DA:1172,12 -DA:1173,12 -DA:1174,12 -DA:1175,12 -DA:1176,12 -DA:1177,12 -DA:1179,12 -DA:1180,12 -DA:1181,12 -DA:1183,12 -DA:1184,12 -DA:1186,12 -DA:1187,12 -DA:1195,50 -DA:1197,49 -DA:1199,49 -DA:1200,39 -DA:1201,30 -DA:1202,30 -DA:1204,30 -DA:1205,30 -DA:1211,30 -DA:1214,0 -DA:1216,0 -DA:1220,0 -DA:1229,13 -DA:1232,12 -DA:1233,9 -DA:1234,9 -DA:1236,9 -DA:1237,9 -DA:1243,9 -DA:1245,0 -DA:1247,0 -DA:1254,12 -DA:1255,12 -DA:1256,12 -DA:1258,12 -DA:1259,12 -DA:1260,12 -DA:1261,12 -DA:1264,12 -DA:1265,12 -DA:1267,12 -DA:1268,12 -DA:1269,12 -DA:1273,12 -DA:1274,12 -DA:1287,52 -DA:1288,52 -DA:1290,52 -DA:1291,52 -DA:1294,52 -DA:1297,52 -DA:1298,52 -DA:1299,52 -DA:1300,52 -DA:1301,52 -DA:1302,52 -DA:1303,52 -DA:1304,52 -DA:1307,52 -DA:1316,55 -DA:1317,55 -DA:1318,165 -DA:1319,110 -DA:1320,110 -DA:1322,55 -DA:1330,54 -DA:1331,53 -DA:1334,53 -DA:1335,1 -DA:1338,53 -DA:1339,52 -DA:1341,1 -DA:1349,52 -DA:1352,52 -DA:1353,52 -DA:1363,134 -DA:1365,134 -DA:1368,134 -DA:1369,134 -DA:1371,3 -DA:1372,2 -DA:1375,2 -DA:1376,1 -DA:1378,1 -DA:1379,1 -DA:1383,1 -DA:1392,804 -DA:1393,584 -DA:1394,584 -DA:1395,582 -DA:1396,582 -DA:1401,12 -DA:1404,12 -DA:1405,12 -LF:527 -LH:509 -BRDA:166,0,0,96 -BRDA:166,0,1,1 -BRDA:175,1,0,97 -BRDA:175,1,1,0 -BRDA:175,2,0,97 -BRDA:175,2,1,97 -BRDA:176,3,0,85 -BRDA:176,3,1,12 -BRDA:184,4,0,97 -BRDA:184,4,1,0 -BRDA:184,5,0,97 -BRDA:184,5,1,97 -BRDA:185,6,0,85 -BRDA:185,6,1,12 -BRDA:206,7,0,86 -BRDA:206,7,1,11 -BRDA:206,8,0,97 -BRDA:206,8,1,97 -BRDA:211,9,0,11 -BRDA:211,9,1,0 -BRDA:214,10,0,11 -BRDA:214,10,1,0 -BRDA:240,11,0,2 -BRDA:240,11,1,29 -BRDA:247,12,0,1 -BRDA:247,12,1,35 -BRDA:254,13,0,1 -BRDA:254,13,1,1 -BRDA:275,14,0,129 -BRDA:275,14,1,129 -BRDA:275,14,2,0 -BRDA:282,15,0,129 -BRDA:282,15,1,0 -BRDA:286,16,0,129 -BRDA:286,16,1,129 -BRDA:286,16,2,22 -BRDA:295,17,0,43 -BRDA:295,17,1,0 -BRDA:318,18,0,565 -BRDA:318,18,1,3 -BRDA:318,19,0,568 -BRDA:318,19,1,426 -BRDA:320,20,0,3 -BRDA:320,20,1,0 -BRDA:324,21,0,142 -BRDA:324,21,1,0 -BRDA:326,22,0,142 -BRDA:326,22,1,0 -BRDA:326,23,0,142 -BRDA:326,23,1,142 -BRDA:328,24,0,0 -BRDA:328,24,1,142 -BRDA:331,25,0,142 -BRDA:331,25,1,0 -BRDA:331,26,0,142 -BRDA:331,26,1,142 -BRDA:335,27,0,0 -BRDA:335,27,1,142 -BRDA:339,28,0,142 -BRDA:339,28,1,0 -BRDA:342,29,0,0 -BRDA:342,29,1,142 -BRDA:344,30,0,0 -BRDA:344,30,1,142 -BRDA:348,31,0,142 -BRDA:348,31,1,0 -BRDA:350,32,0,3 -BRDA:350,32,1,139 -BRDA:363,33,0,0 -BRDA:363,33,1,43 -BRDA:363,34,0,43 -BRDA:363,34,1,43 -BRDA:365,35,0,43 -BRDA:365,35,1,42 -BRDA:375,36,0,0 -BRDA:375,36,1,36 -BRDA:391,37,0,0 -BRDA:391,37,1,97 -BRDA:391,38,0,97 -BRDA:391,38,1,97 -BRDA:393,39,0,97 -BRDA:393,39,1,95 -BRDA:395,40,0,96 -BRDA:395,40,1,1 -BRDA:395,41,0,97 -BRDA:395,41,1,1 -BRDA:414,42,0,98 -BRDA:414,42,1,0 -BRDA:414,43,0,98 -BRDA:414,43,1,98 -BRDA:417,44,0,0 -BRDA:417,44,1,98 -BRDA:417,45,0,98 -BRDA:417,45,1,98 -BRDA:434,46,0,98 -BRDA:434,46,1,0 -BRDA:448,47,0,2 -BRDA:448,47,1,0 -BRDA:448,48,0,2 -BRDA:448,48,1,2 -BRDA:451,49,0,0 -BRDA:451,49,1,2 -BRDA:451,50,0,2 -BRDA:451,50,1,2 -BRDA:483,51,0,2 -BRDA:483,51,1,0 -BRDA:483,52,0,2 -BRDA:483,52,1,2 -BRDA:487,53,0,2 -BRDA:487,53,1,2 -BRDA:489,54,0,2 -BRDA:489,54,1,0 -BRDA:489,55,0,2 -BRDA:489,55,1,2 -BRDA:501,56,0,0 -BRDA:501,56,1,6 -BRDA:527,57,0,1 -BRDA:527,57,1,0 -BRDA:527,58,0,1 -BRDA:527,58,1,1 -BRDA:528,59,0,0 -BRDA:528,59,1,1 -BRDA:533,60,0,0 -BRDA:533,60,1,3 -BRDA:533,61,0,3 -BRDA:533,61,1,3 -BRDA:537,62,0,3 -BRDA:537,62,1,0 -BRDA:548,63,0,1 -BRDA:548,63,1,2 -BRDA:565,64,0,0 -BRDA:565,64,1,9 -BRDA:568,65,0,0 -BRDA:568,65,1,9 -BRDA:570,66,0,1 -BRDA:570,66,1,8 -BRDA:590,67,0,389 -BRDA:590,67,1,324 -BRDA:590,67,2,310 -BRDA:590,67,3,1 -BRDA:607,68,0,8 -BRDA:607,68,1,1 -BRDA:607,69,0,9 -BRDA:607,69,1,8 -BRDA:608,70,0,8 -BRDA:608,70,1,1 -BRDA:608,71,0,9 -BRDA:608,71,1,8 -BRDA:609,72,0,2 -BRDA:609,72,1,7 -BRDA:624,73,0,0 -BRDA:624,73,1,294 -BRDA:629,74,0,291 -BRDA:629,74,1,3 -BRDA:666,75,0,4 -BRDA:666,75,1,1 -BRDA:666,76,0,5 -BRDA:666,76,1,4 -BRDA:667,77,0,4 -BRDA:667,77,1,1 -BRDA:667,78,0,5 -BRDA:667,78,1,4 -BRDA:668,79,0,2 -BRDA:668,79,1,3 -BRDA:679,80,0,1 -BRDA:679,80,1,2 -BRDA:681,81,0,1 -BRDA:681,81,1,1 -BRDA:681,82,0,2 -BRDA:681,82,1,1 -BRDA:701,83,0,0 -BRDA:701,83,1,495 -BRDA:701,84,0,495 -BRDA:701,84,1,495 -BRDA:703,85,0,69 -BRDA:703,85,1,426 -BRDA:721,86,0,1 -BRDA:721,86,1,164 -BRDA:730,87,0,165 -BRDA:730,87,1,105 -BRDA:730,87,2,103 -BRDA:730,87,3,1 -BRDA:748,88,0,54 -BRDA:748,88,1,2 -BRDA:748,88,2,2 -BRDA:748,88,3,1 -BRDA:766,89,0,5 -BRDA:766,89,1,63 -BRDA:766,90,0,68 -BRDA:766,90,1,67 -BRDA:766,90,2,63 -BRDA:767,91,0,1 -BRDA:767,91,1,62 -BRDA:767,92,0,63 -BRDA:767,92,1,63 -BRDA:771,93,0,1 -BRDA:771,93,1,61 -BRDA:777,94,0,60 -BRDA:777,94,1,1 -BRDA:777,95,0,61 -BRDA:777,95,1,61 -BRDA:783,96,0,61 -BRDA:783,96,1,0 -BRDA:798,97,0,0 -BRDA:798,97,1,300 -BRDA:800,98,0,2 -BRDA:800,98,1,298 -BRDA:815,99,0,0 -BRDA:815,99,1,315 -BRDA:819,100,0,0 -BRDA:819,100,1,315 -BRDA:830,101,0,0 -BRDA:830,101,1,315 -BRDA:830,102,0,315 -BRDA:830,102,1,315 -BRDA:833,103,0,315 -BRDA:833,103,1,0 -BRDA:849,104,0,0 -BRDA:849,104,1,50 -BRDA:849,105,0,50 -BRDA:849,105,1,50 -BRDA:862,106,0,1 -BRDA:862,106,1,131 -BRDA:863,107,0,132 -BRDA:863,107,1,132 -BRDA:863,107,2,132 -BRDA:863,107,3,131 -BRDA:884,108,0,11 -BRDA:886,109,0,2 -BRDA:886,109,1,50 -BRDA:889,110,0,24 -BRDA:889,110,1,26 -BRDA:889,111,0,50 -BRDA:889,111,1,24 -BRDA:891,112,0,2 -BRDA:891,112,1,22 -BRDA:891,113,0,24 -BRDA:891,113,1,2 -BRDA:898,114,0,26 -BRDA:898,114,1,22 -BRDA:909,115,0,48 -BRDA:909,115,1,0 -BRDA:923,116,0,22 -BRDA:923,116,1,26 -BRDA:925,117,0,22 -BRDA:925,117,1,0 -BRDA:925,118,0,22 -BRDA:925,118,1,22 -BRDA:931,119,0,22 -BRDA:931,119,1,26 -BRDA:940,120,0,14 -BRDA:942,121,0,2 -BRDA:942,121,1,17 -BRDA:952,122,0,17 -BRDA:952,122,1,0 -BRDA:964,123,0,12 -BRDA:964,123,1,5 -BRDA:964,124,0,17 -BRDA:964,124,1,12 -BRDA:970,125,0,12 -BRDA:970,125,1,5 -BRDA:984,126,0,0 -BRDA:984,126,1,81 -BRDA:985,127,0,74 -BRDA:985,127,1,7 -BRDA:997,128,0,0 -BRDA:999,129,0,4 -BRDA:999,129,1,0 -BRDA:999,130,0,4 -BRDA:999,130,1,4 -BRDA:1008,131,0,0 -BRDA:1008,131,1,0 -BRDA:1008,132,0,0 -BRDA:1008,132,1,0 -BRDA:1022,133,0,2 -BRDA:1022,133,1,13 -BRDA:1024,134,0,3 -BRDA:1024,134,1,10 -BRDA:1035,135,0,0 -BRDA:1035,135,1,9 -BRDA:1052,136,0,0 -BRDA:1052,136,1,106 -BRDA:1052,137,0,106 -BRDA:1052,137,1,106 -BRDA:1055,138,0,0 -BRDA:1055,138,1,106 -BRDA:1055,139,0,106 -BRDA:1055,139,1,106 -BRDA:1061,140,0,6 -BRDA:1061,140,1,100 -BRDA:1067,141,0,4 -BRDA:1067,141,1,102 -BRDA:1073,142,0,3 -BRDA:1073,142,1,103 -BRDA:1086,143,0,0 -BRDA:1086,143,1,13 -BRDA:1089,144,0,1 -BRDA:1089,144,1,12 -BRDA:1089,145,0,13 -BRDA:1089,145,1,13 -BRDA:1092,146,0,1 -BRDA:1092,146,1,11 -BRDA:1092,147,0,12 -BRDA:1092,147,1,12 -BRDA:1103,148,0,0 -BRDA:1103,148,1,305 -BRDA:1105,149,0,1 -BRDA:1105,149,1,304 -BRDA:1105,150,0,305 -BRDA:1105,150,1,305 -BRDA:1107,151,0,304 -BRDA:1107,151,1,304 -BRDA:1108,152,0,299 -BRDA:1108,152,1,5 -BRDA:1110,153,0,5 -BRDA:1110,153,1,0 -BRDA:1110,154,0,5 -BRDA:1110,154,1,5 -BRDA:1120,155,0,0 -BRDA:1120,155,1,9 -BRDA:1123,156,0,0 -BRDA:1123,156,1,9 -BRDA:1123,157,0,9 -BRDA:1123,157,1,7 -BRDA:1126,158,0,9 -BRDA:1126,158,1,0 -BRDA:1126,159,0,9 -BRDA:1126,159,1,9 -BRDA:1127,160,0,0 -BRDA:1127,160,1,9 -BRDA:1130,161,0,0 -BRDA:1130,161,1,9 -BRDA:1131,162,0,0 -BRDA:1131,162,1,9 -BRDA:1131,163,0,9 -BRDA:1131,163,1,9 -BRDA:1133,164,0,2 -BRDA:1133,164,1,7 -BRDA:1134,165,0,2 -BRDA:1134,165,1,7 -BRDA:1143,166,0,9 -BRDA:1143,166,1,0 -BRDA:1143,167,0,9 -BRDA:1143,167,1,9 -BRDA:1154,168,0,1 -BRDA:1154,168,1,16 -BRDA:1154,169,0,17 -BRDA:1154,169,1,17 -BRDA:1154,169,2,17 -BRDA:1157,170,0,15 -BRDA:1157,170,1,1 -BRDA:1157,171,0,16 -BRDA:1157,171,1,16 -BRDA:1158,172,0,1 -BRDA:1158,172,1,15 -BRDA:1161,173,0,1 -BRDA:1161,173,1,14 -BRDA:1163,174,0,2 -BRDA:1163,174,1,12 -BRDA:1168,175,0,12 -BRDA:1168,175,1,0 -BRDA:1176,176,0,12 -BRDA:1176,176,1,0 -BRDA:1176,177,0,12 -BRDA:1176,177,1,12 -BRDA:1195,178,0,1 -BRDA:1195,178,1,49 -BRDA:1195,179,0,50 -BRDA:1195,179,1,49 -BRDA:1195,179,2,49 -BRDA:1199,180,0,10 -BRDA:1199,180,1,39 -BRDA:1202,181,0,0 -BRDA:1202,181,1,30 -BRDA:1202,182,0,30 -BRDA:1202,182,1,30 -BRDA:1202,182,2,30 -BRDA:1229,183,0,1 -BRDA:1229,183,1,12 -BRDA:1232,184,0,9 -BRDA:1232,184,1,3 -BRDA:1232,185,0,12 -BRDA:1232,185,1,9 -BRDA:1232,185,2,9 -BRDA:1264,186,0,12 -BRDA:1264,186,1,0 -BRDA:1264,187,0,12 -BRDA:1264,187,1,12 -BRDA:1268,188,0,12 -BRDA:1268,188,1,0 -BRDA:1273,189,0,12 -BRDA:1273,189,1,0 -BRDA:1273,190,0,12 -BRDA:1273,190,1,12 -BRDA:1299,191,0,52 -BRDA:1299,191,1,0 -BRDA:1299,192,0,52 -BRDA:1299,192,1,52 -BRDA:1302,193,0,52 -BRDA:1302,193,1,0 -BRDA:1318,194,0,55 -BRDA:1318,194,1,110 -BRDA:1330,195,0,53 -BRDA:1330,195,1,1 -BRDA:1331,196,0,53 -BRDA:1331,196,1,0 -BRDA:1334,197,0,1 -BRDA:1334,197,1,52 -BRDA:1334,198,0,53 -BRDA:1334,198,1,53 -BRDA:1334,198,2,53 -BRDA:1338,199,0,52 -BRDA:1338,199,1,1 -BRDA:1338,200,0,53 -BRDA:1338,200,1,53 -BRDA:1353,201,0,0 -BRDA:1353,201,1,52 -BRDA:1365,202,0,134 -BRDA:1365,202,1,132 -BRDA:1365,202,2,2 -BRDA:1369,203,0,131 -BRDA:1369,203,1,3 -BRDA:1371,204,0,2 -BRDA:1371,204,1,1 -BRDA:1372,205,0,2 -BRDA:1372,205,1,0 -BRDA:1375,206,0,1 -BRDA:1375,206,1,1 -BRDA:1375,207,0,2 -BRDA:1375,207,1,2 -BRDA:1375,207,2,2 -BRDA:1378,208,0,1 -BRDA:1378,208,1,0 -BRDA:1378,209,0,1 -BRDA:1378,209,1,1 -BRDA:1392,210,0,220 -BRDA:1392,210,1,584 -BRDA:1392,211,0,804 -BRDA:1392,211,1,584 -BRDA:1394,212,0,2 -BRDA:1394,212,1,582 -BRDA:1396,213,0,581 -BRDA:1396,213,1,1 -BRDA:1404,214,0,12 -BRDA:1404,214,1,0 -BRDA:1404,215,0,12 -BRDA:1404,215,1,12 -BRDA:1407,216,0,12 -BRDA:1407,216,1,0 -BRF:449 -BRH:369 -end_of_record -TN: -SF:static\js\state-manager.js -FN:14,(anonymous_0) -FN:25,(anonymous_1) -FN:40,(anonymous_2) -FN:51,(anonymous_3) -FN:126,(anonymous_4) -FN:198,(anonymous_5) -FN:214,(anonymous_6) -FN:229,(anonymous_7) -FN:247,(anonymous_8) -FN:261,(anonymous_9) -FN:263,(anonymous_10) -FN:277,(anonymous_11) -FN:297,(anonymous_12) -FNF:13 -FNH:13 -FNDA:13,(anonymous_0) -FNDA:360,(anonymous_1) -FNDA:365,(anonymous_2) -FNDA:150,(anonymous_3) -FNDA:203,(anonymous_4) -FNDA:6,(anonymous_5) -FNDA:8,(anonymous_6) -FNDA:1,(anonymous_7) -FNDA:2,(anonymous_8) -FNDA:1,(anonymous_9) -FNDA:2,(anonymous_10) -FNDA:1258,(anonymous_11) -FNDA:106,(anonymous_12) -DA:14,13 -DA:17,13 -DA:18,13 -DA:26,360 -DA:27,360 -DA:29,360 -DA:31,1 -DA:41,365 -DA:42,365 -DA:52,150 -DA:53,150 -DA:54,1 -DA:56,1 -DA:60,1 -DA:63,149 -DA:70,149 -DA:71,1 -DA:73,1 -DA:77,1 -DA:80,148 -DA:81,148 -DA:83,148 -DA:84,148 -DA:85,146 -DA:88,2 -DA:89,1 -DA:91,1 -DA:95,1 -DA:96,1 -DA:97,1 -DA:98,1 -DA:100,0 -DA:102,0 -DA:106,0 -DA:111,1 -DA:113,1 -DA:117,1 -DA:127,203 -DA:128,203 -DA:129,0 -DA:131,0 -DA:135,0 -DA:138,203 -DA:140,203 -DA:141,203 -DA:142,203 -DA:144,3 -DA:146,3 -DA:150,3 -DA:153,200 -DA:155,106 -DA:156,106 -DA:157,106 -DA:160,2 -DA:161,2 -DA:165,2 -DA:167,2 -DA:171,2 -DA:174,104 -DA:175,104 -DA:176,1 -DA:177,1 -DA:181,1 -DA:183,1 -DA:187,1 -DA:190,103 -DA:199,6 -DA:200,6 -DA:201,6 -DA:202,6 -DA:203,6 -DA:215,8 -DA:216,7 -DA:217,7 -DA:218,5 -DA:219,4 -DA:221,4 -DA:230,1 -DA:231,1 -DA:233,1 -DA:234,1 -DA:235,1 -DA:236,4 -DA:237,4 -DA:238,4 -DA:239,3 -DA:240,2 -DA:247,1 -DA:248,2 -DA:249,2 -DA:250,2 -DA:251,2 -DA:252,2 -DA:253,1 -DA:256,1 -DA:258,2 -DA:261,1 -DA:263,1 -DA:264,2 -DA:265,2 -DA:278,1258 -DA:279,1257 -DA:281,1256 -DA:282,1256 -DA:283,3760 -DA:284,3760 -DA:285,3760 -DA:286,3757 -DA:289,1252 -DA:298,106 -DA:301,106 -DA:302,1 -DA:306,105 -DA:309,1 -DA:314,13 -DA:317,13 -DA:318,13 -LF:117 -LH:111 -BRDA:27,0,0,0 -BRDA:27,0,1,360 -BRDA:29,1,0,360 -BRDA:29,1,1,1 -BRDA:41,2,0,363 -BRDA:41,2,1,2 -BRDA:41,3,0,365 -BRDA:41,3,1,363 -BRDA:53,4,0,1 -BRDA:53,4,1,149 -BRDA:65,5,0,149 -BRDA:65,5,1,0 -BRDA:65,6,0,149 -BRDA:65,6,1,149 -BRDA:66,7,0,149 -BRDA:66,7,1,0 -BRDA:66,8,0,149 -BRDA:66,8,1,149 -BRDA:70,9,0,1 -BRDA:70,9,1,148 -BRDA:88,10,0,1 -BRDA:88,10,1,1 -BRDA:128,11,0,0 -BRDA:128,11,1,203 -BRDA:153,12,0,94 -BRDA:153,12,1,106 -BRDA:175,13,0,1 -BRDA:175,13,1,103 -BRDA:201,14,0,0 -BRDA:201,14,1,6 -BRDA:215,15,0,1 -BRDA:215,15,1,7 -BRDA:216,16,0,5 -BRDA:216,16,1,2 -BRDA:217,17,0,2 -BRDA:217,17,1,5 -BRDA:218,18,0,1 -BRDA:218,18,1,4 -BRDA:219,19,0,3 -BRDA:219,19,1,1 -BRDA:221,20,0,4 -BRDA:221,20,1,3 -BRDA:231,21,0,0 -BRDA:231,21,1,1 -BRDA:237,22,0,0 -BRDA:237,22,1,4 -BRDA:238,23,0,1 -BRDA:238,23,1,3 -BRDA:239,24,0,1 -BRDA:239,24,1,2 -BRDA:239,25,0,3 -BRDA:239,25,1,3 -BRDA:251,26,0,2 -BRDA:251,26,1,0 -BRDA:253,27,0,1 -BRDA:253,27,1,0 -BRDA:253,28,0,1 -BRDA:253,28,1,1 -BRDA:278,29,0,1 -BRDA:278,29,1,1257 -BRDA:278,30,0,1258 -BRDA:278,30,1,1257 -BRDA:279,31,0,1 -BRDA:279,31,1,1256 -BRDA:279,32,0,1257 -BRDA:279,32,1,1257 -BRDA:284,33,0,0 -BRDA:284,33,1,3760 -BRDA:284,34,0,3760 -BRDA:284,34,1,3760 -BRDA:285,35,0,3 -BRDA:285,35,1,3757 -BRDA:285,36,0,3760 -BRDA:285,36,1,3760 -BRDA:286,37,0,1 -BRDA:286,37,1,3756 -BRDA:298,38,0,0 -BRDA:298,38,1,106 -BRDA:298,39,0,106 -BRDA:298,39,1,106 -BRDA:301,40,0,1 -BRDA:301,40,1,105 -BRDA:306,41,0,104 -BRDA:306,41,1,1 -BRDA:317,42,0,13 -BRDA:317,42,1,0 -BRDA:317,43,0,13 -BRDA:317,43,1,13 -BRDA:320,44,0,13 -BRDA:320,44,1,0 -BRF:90 -BRH:77 -end_of_record diff --git a/tests/layout-system/integration/drag-resize.test.js b/tests/layout-system/integration/drag-resize.test.js deleted file mode 100644 index 6876bef..0000000 --- a/tests/layout-system/integration/drag-resize.test.js +++ /dev/null @@ -1,112 +0,0 @@ -/** - * 集成测试 - 拖动调整宽度完整流程(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(); - } - }); -}); diff --git a/tests/layout-system/integration/keyboard-resizer.test.js b/tests/layout-system/integration/keyboard-resizer.test.js deleted file mode 100644 index bd67b2d..0000000 --- a/tests/layout-system/integration/keyboard-resizer.test.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * 集成测试 - 键盘可访问性(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'); - }); -}); - diff --git a/tests/layout-system/integration/panel-collapse.test.js b/tests/layout-system/integration/panel-collapse.test.js deleted file mode 100644 index 89d59fe..0000000 --- a/tests/layout-system/integration/panel-collapse.test.js +++ /dev/null @@ -1,115 +0,0 @@ -/** - * 集成测试 - 面板折叠/展开完整流程 - * - * 覆盖: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); - }); -}); diff --git a/tests/layout-system/integration/reset-layout.test.js b/tests/layout-system/integration/reset-layout.test.js deleted file mode 100644 index f3d22be..0000000 --- a/tests/layout-system/integration/reset-layout.test.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * 集成测试 - 恢复默认布局(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(); - }); -}); - diff --git a/tests/layout-system/integration/state-error-handling.test.js b/tests/layout-system/integration/state-error-handling.test.js deleted file mode 100644 index 0f22e26..0000000 --- a/tests/layout-system/integration/state-error-handling.test.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 集成测试 - 状态持久化错误处理(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(); - } - }); -}); - diff --git a/tests/layout-system/integration/state-load.test.js b/tests/layout-system/integration/state-load.test.js deleted file mode 100644 index 697305e..0000000 --- a/tests/layout-system/integration/state-load.test.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 集成测试 - 状态加载(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' - ); - }); -}); - diff --git a/tests/layout-system/integration/state-multi-user.test.js b/tests/layout-system/integration/state-multi-user.test.js deleted file mode 100644 index c2bd250..0000000 --- a/tests/layout-system/integration/state-multi-user.test.js +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 集成测试 - 多用户隔离(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; - }); -}); - diff --git a/tests/layout-system/integration/state-save.test.js b/tests/layout-system/integration/state-save.test.js deleted file mode 100644 index c8df393..0000000 --- a/tests/layout-system/integration/state-save.test.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * 集成测试 - 状态保存(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(); - } - }); -}); - diff --git a/tests/layout-system/integration/window-adapt.test.js b/tests/layout-system/integration/window-adapt.test.js deleted file mode 100644 index 4409b80..0000000 --- a/tests/layout-system/integration/window-adapt.test.js +++ /dev/null @@ -1,125 +0,0 @@ -/** - * 集成测试 - 窗口尺寸适配(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'); - }); -}); - diff --git a/tests/layout-system/jest.config.js b/tests/layout-system/jest.config.js deleted file mode 100644 index 0c99fbd..0000000 --- a/tests/layout-system/jest.config.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Jest 配置文件 - 布局系统测试 - */ - -module.exports = { - // 测试环境 - testEnvironment: 'jsdom', - - // 根目录 - rootDir: '../../', - - // 测试文件匹配模式 - testMatch: [ - '**/tests/layout-system/unit/**/*.test.js', - '**/tests/layout-system/integration/**/*.test.js', - '**/tests/layout-system/performance/**/*.test.js', - '**/tests/layout-system/accessibility/**/*.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: ['/tests/layout-system/setup.js'] -}; diff --git a/tests/layout-system/performance/layout-performance.test.js b/tests/layout-system/performance/layout-performance.test.js deleted file mode 100644 index 89165fc..0000000 --- a/tests/layout-system/performance/layout-performance.test.js +++ /dev/null @@ -1,270 +0,0 @@ -/** - * 布局系统性能测试 - * - * 测试目标: - * - 拖动帧率 ≥ 60fps(RAF 节流验证) - * - 折叠动画时长 200ms - * - localStorage 数据 < 1KB - * - 状态保存防抖 500ms - * - DOM 批量操作无冗余 - * - * 参考文档:TD 第七章 - 性能测试方案 - */ - -const { - createLayoutDOM, - getCSSVariable, - setCSSVariable, - simulateDrag, - wait, - createTestState, - setWindowSize -} = require('../test-utils'); - -const LayoutManager = require('../../../static/js/layout-manager.js'); -const StateManager = require('../../../static/js/state-manager.js'); - -// ======================== -// 1. RAF 节流验证 -// ======================== -describe('性能测试 - RAF 节流', () => { - let manager; - - beforeEach(() => { - createLayoutDOM(); - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 }); - manager = new LayoutManager(); - manager.init(); - }); - - test('拖动期间应使用 requestAnimationFrame 节流', () => { - const rafSpy = jest.spyOn(global, 'requestAnimationFrame'); - - const resizer = document.querySelector('[data-resizer="groups"]'); - const mousedown = new MouseEvent('mousedown', { - bubbles: true, - cancelable: true, - clientX: 200 - }); - - resizer.dispatchEvent(mousedown); - - // 快速连续触发多次 mousemove - for (let i = 0; i < 10; i++) { - const mousemove = new MouseEvent('mousemove', { - bubbles: true, - cancelable: true, - clientX: 200 + i * 5 - }); - document.dispatchEvent(mousemove); - } - - // RAF 应该被调用,但不应每次 mousemove 都产生独立的 RAF - // 由于 RAF mock 是同步 setTimeout(cb, 16),实际调用数取决于实现 - expect(rafSpy).toHaveBeenCalled(); - - // 停止拖动 - document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); - rafSpy.mockRestore(); - }); - - test('非拖动状态下 mousemove 不应触发 RAF', () => { - const rafSpy = jest.spyOn(global, 'requestAnimationFrame'); - const callsBefore = rafSpy.mock.calls.length; - - const mousemove = new MouseEvent('mousemove', { - bubbles: true, - cancelable: true, - clientX: 300 - }); - document.dispatchEvent(mousemove); - - expect(rafSpy.mock.calls.length).toBe(callsBefore); - rafSpy.mockRestore(); - }); -}); - -// ======================== -// 2. 防抖保存验证 -// ======================== -describe('性能测试 - 状态保存防抖', () => { - let manager; - - beforeEach(() => { - createLayoutDOM(); - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 }); - manager = new LayoutManager(); - manager.init(); - }); - - test('saveState 应使用防抖机制', async () => { - // 验证 saveState 方法存在且可调用 - if (typeof manager.saveState === 'function') { - // saveState 在有 stateManager 时应触发保存(防抖或直接保存) - // 关键验证:多次调用不会产生多次写入 - const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); - - manager.saveState(); - manager.saveState(); - manager.saveState(); - - // 无论防抖还是直接保存,连续调用不应产生 3 次独立的写入 - const callCount = setItemSpy.mock.calls.length; - expect(callCount).toBeLessThanOrEqual(3); - setItemSpy.mockRestore(); - } - }); - - test('saveStateNow 应立即写入 localStorage', () => { - const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); - - if (typeof manager.saveStateNow === 'function') { - manager.saveStateNow(); - expect(setItemSpy).toHaveBeenCalled(); - } - - setItemSpy.mockRestore(); - }); -}); - -// ======================== -// 3. localStorage 数据大小 -// ======================== -describe('性能测试 - localStorage 数据大小', () => { - test('保存的状态数据应 < 1KB', () => { - const sm = new StateManager(); - const state = createTestState('testuser'); - - sm.save('testuser', state); - - const key = 'outlook_layout_state_testuser'; - const data = localStorage.getItem(key); - - if (data) { - const sizeInBytes = new Blob([data]).size; - expect(sizeInBytes).toBeLessThan(1024); // < 1KB - } - }); - - test('状态数据应为有效 JSON', () => { - const sm = new StateManager(); - const state = createTestState('testuser'); - - sm.save('testuser', state); - - const key = 'outlook_layout_state_testuser'; - const data = localStorage.getItem(key); - - if (data) { - expect(() => JSON.parse(data)).not.toThrow(); - } - }); -}); - -// ======================== -// 4. 折叠动画时间配置验证 -// ======================== -describe('性能测试 - 动画配置', () => { - beforeEach(() => { - createLayoutDOM(); - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 }); - }); - - test('CSS 变量 --panel-transition-duration 应为 200ms', () => { - // 设置 CSS 变量(模拟 layout.css 加载后的状态) - setCSSVariable('--panel-transition-duration', '200ms'); - const duration = getCSSVariable('--panel-transition-duration'); - expect(duration).toBe('200ms'); - }); - - test('折叠操作应添加/移除 collapsed 类', () => { - const manager = new LayoutManager(); - manager.init(); - - manager.collapsePanel('groups'); - const panel = document.querySelector('[data-panel="groups"]'); - expect(panel.classList.contains('collapsed')).toBe(true); - - manager.expandPanel('groups'); - expect(panel.classList.contains('collapsed')).toBe(false); - }); -}); - -// ======================== -// 5. 批量 DOM 操作验证 -// ======================== -describe('性能测试 - DOM 操作效率', () => { - let manager; - - beforeEach(() => { - createLayoutDOM(); - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 }); - manager = new LayoutManager(); - manager.init(); - }); - - test('updatePanelWidth 应通过 CSS 变量批量更新而非直接操作 style.width', () => { - // updatePanelWidth 接受 panel DOM 元素和宽度数值 - const panel = document.querySelector('[data-panel="groups"]'); - manager.updatePanelWidth(panel, 250); - - const cssVar = getCSSVariable('--group-panel-width'); - expect(cssVar).toBe('250px'); - - // 面板元素不应直接设置 style.width(由 CSS Grid 通过变量控制) - expect(panel.style.width).toBe(''); - }); - - test('resetLayout 应一次性恢复所有面板宽度', () => { - // 先修改宽度 - manager.updatePanelWidth('groups', 300); - manager.updatePanelWidth('accounts', 400); - manager.updatePanelWidth('emails', 500); - - // 重置 - manager.resetLayout(); - - // 验证所有面板恢复默认值 - expect(getCSSVariable('--group-panel-width')).toBe('200px'); - expect(getCSSVariable('--account-panel-width')).toBe('260px'); - expect(getCSSVariable('--email-list-panel-width')).toBe('380px'); - }); -}); - -// ======================== -// 6. 状态管理器性能 -// ======================== -describe('性能测试 - StateManager 操作速度', () => { - test('save/load 操作应在 5ms 内完成', () => { - const sm = new StateManager(); - const state = createTestState('perftest'); - - // 测试 save 性能 - const saveStart = performance.now(); - for (let i = 0; i < 100; i++) { - sm.save('perftest', state); - } - const saveTime = (performance.now() - saveStart) / 100; - expect(saveTime).toBeLessThan(5); - - // 测试 load 性能 - const loadStart = performance.now(); - for (let i = 0; i < 100; i++) { - sm.load('perftest'); - } - const loadTime = (performance.now() - loadStart) / 100; - expect(loadTime).toBeLessThan(5); - }); - - test('validate 操作应在 1ms 内完成', () => { - const sm = new StateManager(); - const state = createTestState('perftest'); - - const start = performance.now(); - for (let i = 0; i < 1000; i++) { - sm.validate(state); - } - const avgTime = (performance.now() - start) / 1000; - expect(avgTime).toBeLessThan(1); - }); -}); diff --git a/tests/layout-system/setup.js b/tests/layout-system/setup.js deleted file mode 100644 index 868945f..0000000 --- a/tests/layout-system/setup.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * 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 = ''; - } -}); diff --git a/tests/layout-system/test-utils.js b/tests/layout-system/test-utils.js deleted file mode 100644 index 98dc943..0000000 --- a/tests/layout-system/test-utils.js +++ /dev/null @@ -1,297 +0,0 @@ -/** - * 测试辅助工具(布局系统) - * - * 说明: - * - 本文件用于 Jest(jsdom) 环境下快速搭建 DOM、模拟事件、读取 CSS 变量。 - * - 测试用 DOM 结构尽量与 TDD 第八章的集成结构一致,便于后续集成/回归测试复用。 - */ - -/** - * 创建完整的布局 DOM 结构 - */ -function createLayoutDOM() { - const html = ` -
- -
-
-

分组

- -
-
-

分组内容

-
- -
- - -
-
-

账号

- -
-
-

账号内容

-
- -
- - -
-
-

邮件列表

- -
-
-

邮件列表内容

-
- -
- - - -
- `; - - 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 = ` -
-
-
-
-
- `; - - 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} - */ -function wait(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * 等待过渡动画完成(监听 transitionend) - * @param {HTMLElement} element - * @param {number} timeout - * @returns {Promise} - */ -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} 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 -}; - diff --git a/tests/layout-system/unit/layout-manager.test.js b/tests/layout-system/unit/layout-manager.test.js deleted file mode 100644 index d6af5ab..0000000 --- a/tests/layout-system/unit/layout-manager.test.js +++ /dev/null @@ -1,623 +0,0 @@ -/** - * 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); - }); - }); -}); diff --git a/tests/layout-system/unit/state-manager.test.js b/tests/layout-system/unit/state-manager.test.js deleted file mode 100644 index 7891f1d..0000000 --- a/tests/layout-system/unit/state-manager.test.js +++ /dev/null @@ -1,266 +0,0 @@ -/** - * 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 - }); - } - }); -}); diff --git a/tests/test_api_extract_verification.py b/tests/test_api_extract_verification.py deleted file mode 100644 index 7405238..0000000 --- a/tests/test_api_extract_verification.py +++ /dev/null @@ -1,237 +0,0 @@ -""" -验证码提取 API 集成测试 - -测试验证码提取 API 的正确性,包括: -- API 成功提取验证码和链接 -- API 错误处理(未找到、未登录、邮箱不存在) -- API 性能测试 -- 设置 API 测试(轮询配置) -""" - -import unittest -import sys -import os -import time - -# 添加项目根目录到 Python 路径 -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - - -class TestExtractVerificationAPI(unittest.TestCase): - """验证码提取 API 集成测试类""" - - def setUp(self): - """测试前置准备""" - # TODO: 实现后取消注释 - # from outlook_web.app import create_app - # self.app = create_app() - # self.client = self.app.test_client() - pass - - def tearDown(self): - """测试后清理""" - pass - - # ==================== 验证码提取 API 测试 ==================== - - def test_api_extract_verification_success(self): - """ - 测试用例 IT-001:API 成功提取验证码和链接 - - 测试目的:验证 API 能正确返回验证码和链接 - """ - # TODO: 实现后取消注释 - # # 登录 - # self.client.post('/login', data={'password': 'admin123'}) - # - # # 调用 API - # response = self.client.get('/api/emails/test@example.com/extract-verification') - # data = response.get_json() - # - # # 验证响应 - # self.assertEqual(response.status_code, 200) - # self.assertTrue(data['success']) - # self.assertIn('verification_code', data['data']) - # self.assertIn('links', data['data']) - # self.assertIn('formatted', data['data']) - - self.assertTrue(True, "待实现:API 成功提取") - - def test_api_extract_verification_not_found(self): - """ - 测试用例 IT-002:API 未找到验证信息 - - 测试目的:验证 API 能正确处理未找到验证信息的情况 - """ - # TODO: 实现后取消注释 - # self.client.post('/login', data={'password': 'admin123'}) - # - # response = self.client.get('/api/emails/empty@example.com/extract-verification') - # data = response.get_json() - # - # self.assertEqual(response.status_code, 404) - # self.assertFalse(data['success']) - # self.assertEqual(data['error'], "未找到验证信息") - # self.assertIn('trace_id', data) - - self.assertTrue(True, "待实现:API 未找到验证信息") - - def test_api_extract_verification_unauthorized(self): - """ - 测试用例 IT-003:API 未登录访问 - - 测试目的:验证 API 需要登录验证 - """ - # TODO: 实现后取消注释 - # response = self.client.get('/api/emails/test@example.com/extract-verification') - # self.assertIn(response.status_code, [302, 401]) - - self.assertTrue(True, "待实现:API 未登录访问") - - def test_api_extract_verification_email_not_exist(self): - """ - 测试用例 IT-004:API 邮箱不存在 - - 测试目的:验证 API 能正确处理邮箱不存在的情况 - """ - # TODO: 实现后取消注释 - # self.client.post('/login', data={'password': 'admin123'}) - # - # response = self.client.get('/api/emails/notexist@example.com/extract-verification') - # data = response.get_json() - # - # self.assertEqual(response.status_code, 404) - # self.assertFalse(data['success']) - # self.assertIn('邮箱不存在', data['error']) - - self.assertTrue(True, "待实现: 邮箱不存在") - - def test_api_extract_verification_performance(self): - """ - 测试用例 IT-005:API 性能测试 - - 测试目的:验证 API 响应时间 < 2 秒 - """ - # TODO: 实现后取消注释 - # self.client.post('/login', data={'password': 'admin123'}) - # - # start_time = time.time() - # - # response = self.client.get('/api/emails/test@example.com/extract-verification') - # - # end_time = time.time() - # elapsed_time = (end_time - start_time) * 1000 # 转换为毫秒 - # - # self.assertEqual(response.status_code, 200) - # self.assertLess(elapsed_time, 2000, f"API 响应时间过长: {elapsed_time}ms") - - self.assertTrue(True, "待实现:API 性能测试") - - # ==================== 设置 API 测试 ==================== - - def test_api_get_polling_settings(self): - """ - 测试用例 IT-006:读取轮询配置 - - 测试目的:验证 GET /api/settings 能正确返回轮询配置 - """ - # TODO: 实现后取消注释 - # self.client.post('/login', data={'password': 'admin123'}) - # - # response = self.client.get('/api/settings') - # data = response.get_json() - # - # self.assertEqual(response.status_code, 200) - # self.assertTrue(data['success']) - # self.assertIn('enable_auto_polling', data['settings']) - # self.assertIn('polling_interval', data['settings']) - # self.assertIn('polling_count', data['settings']) - - self.assertTrue(True, "待实现:读取轮询配置") - - def test_api_update_polling_settings(self): - """ - 测试用例 IT-007:更新轮询配置 - - 测试目的:验证 PUT /api/settings 能正确更新轮询配置 - """ - # TODO: 实现后取消注释 - # self.client.post('/login', data={'password': 'admin123'}) - # - # # 更新配置 - # response = self.client.put('/api/settings', - # json={ - # 'enable_auto_polling': True, - # 'polling_interval': 30, - # 'polling_count': 10 - # }, - # content_type='application/json' - # ) - # data = response.get_json() - # - # self.assertEqual(response.status_code, 200) - # self.assertTrue(data['success']) - # - # # 验证更新成功 - # response2 = self.client.get('/api/settings') - # data2 = response2.get_json() - # - # self.assertTrue(data2['settings']['enable_auto_polling']) - # self.assertEqual(data2['settings']['polling_interval'], 30) - # self.assertEqual(data2['settings']['polling_count'], 10) - - self.assertTrue(True, "待实现:更新轮询配置") - - def test_api_update_polling_interval_validation(self): - """ - 测试用例 IT-008:轮询间隔参数验证 - - 测试目的:验证轮询间隔参数验证(5-300 秒) - """ - # TODO: 实现后取消注释 - # self.client.post('/login', data={'password': 'admin123'}) - # - # # 测试小于 5 - # response1 = self.client.put('/api/settings', - # json={'polling_interval': 3}, - # content_type='application/json' - # ) - # self.assertEqual(response1.status_code, 400) - # - # # 测试大于 300 - # response2 = self.client.put('/api/settings', - # json={'polling_interval': 500}, - # content_type='application/json' - # ) - # self.assertEqual(response2.status_code, 400) - - self.assertTrue(True, "待实现:轮询间隔参数验证") - - def test_api_update_polling_count_validation(self): - """ - 测试用例 IT-009:轮询次数参数验证 - - 测试目的:验证轮询次数参数验证(1-100 次) - """ - # TODO: 实现后取消注释 - # self.client.post('/login', data={'password': 'admin123'}) - # - # # 测试小于 1 - # response1 = self.client.put('/api/settings', - # json={'polling_count': 0}, - # content_type='application/json' - # ) - # self.assertEqual(response1.status_code, 400) - # - # # 测试大于 100 - # response2 = self.client.put('/api/settings', - # json={'polling_count': 150}, - # content_type='application/json' - # ) - # self.assertEqual(response2.status_code, 400) - - self.assertTrue(True, "待实现:轮询次数参数验证") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_core_features.py b/tests/test_core_features.py deleted file mode 100644 index 5f704f9..0000000 --- a/tests/test_core_features.py +++ /dev/null @@ -1,253 +0,0 @@ -import json -import unittest -import uuid -from unittest.mock import patch - -from tests._import_app import import_web_app_module, clear_login_attempts - - -class CoreFeatureTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.module = import_web_app_module() - cls.app = cls.module.app - # 导入 graph_service 用于 mock - from outlook_web.services import graph as graph_service - - cls.graph_service = graph_service - - def setUp(self): - # 每个测试前清理登录限制记录,避免测试间互相影响 - with self.app.app_context(): - clear_login_attempts() - - def _login(self, client, password: str = "testpass123"): - resp = client.post("/login", json={"password": password}) - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("success"), True) - return resp - - def test_logout_revokes_session(self): - client = self.app.test_client() - self._login(client) - - resp = client.get("/logout", follow_redirects=False) - self.assertIn(resp.status_code, (301, 302)) - - resp = client.get("/api/system/health") - self.assertEqual(resp.status_code, 401) - data = resp.get_json() - self.assertEqual(data.get("success"), False) - self.assertEqual(data.get("need_login"), True) - - def test_group_crud_and_audit_trace_id(self): - client = self.app.test_client() - self._login(client) - - unique = uuid.uuid4().hex - group_name = f"g_{unique}" - - create = client.post( - "/api/groups", - json={ - "name": group_name, - "description": "desc", - "color": "#123456", - "proxy_url": "", - }, - ) - self.assertEqual(create.status_code, 200) - create_data = create.get_json() - self.assertEqual(create_data.get("success"), True) - group_id = create_data.get("group_id") - self.assertIsInstance(group_id, int) - - create_trace_id = create.headers.get("X-Trace-Id") - self.assertTrue(create_trace_id) - - audit = client.get("/api/audit-logs?resource_type=group&limit=200") - self.assertEqual(audit.status_code, 200) - audit_data = audit.get_json() - self.assertEqual(audit_data.get("success"), True) - logs = audit_data.get("logs") or [] - self.assertTrue(any(l.get("trace_id") == create_trace_id for l in logs)) - - update = client.put( - f"/api/groups/{group_id}", - json={ - "name": group_name, - "description": "desc2", - "color": "#654321", - "proxy_url": "", - }, - ) - self.assertEqual(update.status_code, 200) - self.assertEqual(update.get_json().get("success"), True) - - delete = client.delete(f"/api/groups/{group_id}") - self.assertEqual(delete.status_code, 200) - self.assertEqual(delete.get_json().get("success"), True) - - # 删除后再次获取:应返回错误(legacy 会被归一化为结构化错误,HTTP 状态码为 400) - get_after = client.get(f"/api/groups/{group_id}") - self.assertEqual(get_after.status_code, 400) - data = get_after.get_json() - self.assertEqual(data.get("success"), False) - self.assertIsInstance(data.get("error"), dict) - self.assertEqual(data["error"].get("message"), "分组不存在") - - def test_tag_crud_and_duplicate_name(self): - client = self.app.test_client() - self._login(client) - - unique = uuid.uuid4().hex - tag_name = f"t_{unique}" - - create = client.post("/api/tags", json={"name": tag_name, "color": "#abcdef"}) - self.assertEqual(create.status_code, 200) - data = create.get_json() - self.assertEqual(data.get("success"), True) - tag_id = data.get("tag", {}).get("id") - self.assertIsInstance(tag_id, int) - - dup = client.post("/api/tags", json={"name": tag_name, "color": "#000000"}) - self.assertEqual(dup.status_code, 400) - dup_data = dup.get_json() - self.assertEqual(dup_data.get("success"), False) - self.assertIsInstance(dup_data.get("error"), dict) - self.assertEqual(dup_data["error"].get("message"), "标签名称已存在") - - listing = client.get("/api/tags") - self.assertEqual(listing.status_code, 200) - listing_data = listing.get_json() - self.assertEqual(listing_data.get("success"), True) - self.assertIn( - tag_name, [t.get("name") for t in (listing_data.get("tags") or [])] - ) - - delete = client.delete(f"/api/tags/{tag_id}") - self.assertEqual(delete.status_code, 200) - self.assertEqual(delete.get_json().get("success"), True) - - def test_export_verify_and_export_all_download_contains_account(self): - client = self.app.test_client() - self._login(client) - - unique = uuid.uuid4().hex - email_addr = f"export_{unique}@example.com" - client_id = "cid_" + unique - refresh_token = "rt_" + unique - - conn = self.module.create_sqlite_connection() - try: - conn.execute( - """ - INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - (email_addr, "pw", client_id, refresh_token, 1, "", "active"), - ) - conn.commit() - finally: - conn.close() - - verify = client.post("/api/export/verify", json={"password": "testpass123"}) - self.assertEqual(verify.status_code, 200) - verify_data = verify.get_json() - self.assertEqual(verify_data.get("success"), True) - verify_token = verify_data.get("verify_token") - self.assertTrue(verify_token) - - # 使用请求头传递 token(避免 URL/日志泄露) - export = client.get( - "/api/accounts/export", headers={"X-Export-Token": verify_token} - ) - self.assertEqual(export.status_code, 200) - self.assertIn("text/plain", export.headers.get("Content-Type", "")) - body = export.get_data(as_text=True) - self.assertIn(email_addr, body) - self.assertIn(client_id, body) - self.assertIn(refresh_token, body) - - export_trace_id = export.headers.get("X-Trace-Id") - self.assertTrue(export_trace_id) - - audit = client.get("/api/audit-logs?limit=200") - self.assertEqual(audit.status_code, 200) - audit_data = audit.get_json() - self.assertEqual(audit_data.get("success"), True) - logs = audit_data.get("logs") or [] - self.assertTrue(any(l.get("trace_id") == export_trace_id for l in logs)) - - def test_oauth_auth_url_endpoint(self): - client = self.app.test_client() - self._login(client) - - resp = client.get("/api/oauth/auth-url") - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("success"), True) - self.assertIn("auth_url", data) - self.assertIn("login.microsoftonline.com", data.get("auth_url", "")) - - def test_temp_email_generate_and_list_mocked(self): - client = self.app.test_client() - self._login(client) - - email_addr = f"temp_{uuid.uuid4().hex}@example.com" - # Mock generate_temp_email 返回元组 (email_addr, None) - # 注意:控制器现在直接使用 gptmail service,需要 mock outlook_web.services.gptmail - from outlook_web.services import gptmail as gptmail_service - - with patch.object( - gptmail_service, "generate_temp_email", return_value=(email_addr, None) - ): - created = client.post( - "/api/temp-emails/generate", json={"prefix": "x", "domain": "y"} - ) - self.assertEqual(created.status_code, 200) - created_data = created.get_json() - self.assertEqual(created_data.get("success"), True) - self.assertEqual(created_data.get("email"), email_addr) - - listing = client.get("/api/temp-emails") - self.assertEqual(listing.status_code, 200) - listing_data = listing.get_json() - self.assertEqual(listing_data.get("success"), True) - self.assertIn( - email_addr, [e.get("email") for e in (listing_data.get("emails") or [])] - ) - - def test_refresh_all_stream_has_start_and_complete_events(self): - client = self.app.test_client() - self._login(client) - - conn = self.module.create_sqlite_connection() - try: - conn.execute( - "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", - ("refresh_delay_seconds", "0"), - ) - conn.commit() - finally: - conn.close() - - with patch.object( - self.graph_service, "test_refresh_token", return_value=(True, None) - ): - resp = client.get("/api/accounts/refresh-all") - - self.assertEqual(resp.status_code, 200) - self.assertIn("text/event-stream", resp.headers.get("Content-Type", "")) - - payload = resp.get_data(as_text=True) - events = [] - for line in payload.splitlines(): - if line.startswith("data: "): - events.append(json.loads(line[len("data: ") :])) - - self.assertGreaterEqual(len(events), 2) - self.assertEqual(events[0].get("type"), "start") - self.assertTrue(events[0].get("trace_id")) - self.assertTrue(any(e.get("type") == "complete" for e in events)) diff --git a/tests/test_distributed_lock.py b/tests/test_distributed_lock.py deleted file mode 100644 index 6d05d04..0000000 --- a/tests/test_distributed_lock.py +++ /dev/null @@ -1,64 +0,0 @@ -import time -import unittest -import uuid - -from tests._import_app import import_web_app_module - - -class DistributedLockTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.module = import_web_app_module() - - def test_acquire_and_release_distributed_lock(self): - conn = self.module.create_sqlite_connection() - try: - lock_name = "test_lock_" + uuid.uuid4().hex - owner_a = "a_" + uuid.uuid4().hex - owner_b = "b_" + uuid.uuid4().hex - - ok, info = self.module.acquire_distributed_lock( - conn, lock_name, owner_a, ttl_seconds=60 - ) - self.assertTrue(ok) - self.assertIsNone(info) - - ok2, info2 = self.module.acquire_distributed_lock( - conn, lock_name, owner_b, ttl_seconds=60 - ) - self.assertFalse(ok2) - self.assertIsInstance(info2, dict) - self.assertEqual(info2.get("owner_id"), owner_a) - - released = self.module.release_distributed_lock(conn, lock_name, owner_a) - self.assertTrue(released) - - ok3, info3 = self.module.acquire_distributed_lock( - conn, lock_name, owner_b, ttl_seconds=60 - ) - self.assertTrue(ok3) - self.assertIsNone(info3) - finally: - conn.close() - - def test_expired_lock_can_be_taken_over(self): - conn = self.module.create_sqlite_connection() - try: - lock_name = "test_lock_expired_" + uuid.uuid4().hex - owner_a = "a_" + uuid.uuid4().hex - owner_b = "b_" + uuid.uuid4().hex - - ok, _ = self.module.acquire_distributed_lock( - conn, lock_name, owner_a, ttl_seconds=1 - ) - self.assertTrue(ok) - time.sleep(1.2) - - ok2, info2 = self.module.acquire_distributed_lock( - conn, lock_name, owner_b, ttl_seconds=60 - ) - self.assertTrue(ok2) - self.assertIsInstance(info2, dict) - self.assertEqual(info2.get("previous_owner_id"), owner_a) - finally: - conn.close() diff --git a/tests/test_error_and_trace.py b/tests/test_error_and_trace.py deleted file mode 100644 index e644532..0000000 --- a/tests/test_error_and_trace.py +++ /dev/null @@ -1,475 +0,0 @@ -import json -import unittest -import uuid -from unittest.mock import patch - -from tests._import_app import import_web_app_module, clear_login_attempts - - -class ErrorAndTraceTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.module = import_web_app_module() - cls.app = cls.module.app - - def setUp(self): - # 每个测试前清理登录限制记录,避免测试间互相影响 - with self.app.app_context(): - clear_login_attempts() - - def _login(self, client, password: str = "testpass123"): - resp = client.post("/login", json={"password": password}) - self.assertEqual(resp.status_code, 200) - return resp - - def test_healthz(self): - client = self.app.test_client() - resp = client.get("/healthz") - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("status"), "ok") - - def test_login_required_returns_structured_error(self): - client = self.app.test_client() - resp = client.get("/api/system/health") - self.assertEqual(resp.status_code, 401) - data = resp.get_json() - self.assertEqual(data.get("success"), False) - self.assertEqual(data.get("need_login"), True) - self.assertIsInstance(data.get("error"), dict) - self.assertEqual(data["error"].get("code"), "AUTH_REQUIRED") - self.assertEqual(data["error"].get("status"), 401) - self.assertTrue(data["error"].get("trace_id")) - - def test_api_404_has_structured_error_and_trace_id(self): - client = self.app.test_client() - resp = client.get("/api/__not_found__") - self.assertEqual(resp.status_code, 404) - data = resp.get_json() - self.assertIsInstance(data, dict) - self.assertEqual(data.get("success"), False) - self.assertIsInstance(data.get("error"), dict) - self.assertEqual(data["error"].get("code"), "HTTP_ERROR") - self.assertEqual(data["error"].get("status"), 404) - self.assertTrue(data["error"].get("trace_id")) - self.assertEqual(resp.headers.get("X-Trace-Id"), data["error"].get("trace_id")) - - def test_trace_id_can_be_propagated_from_request(self): - client = self.app.test_client() - resp = client.get( - "/api/__not_found__", headers={"X-Trace-Id": "trace_from_test"} - ) - data = resp.get_json() - self.assertEqual(resp.headers.get("X-Trace-Id"), "trace_from_test") - self.assertEqual(data["error"].get("trace_id"), "trace_from_test") - - def test_sanitize_error_details_masks_tokens(self): - sanitized = self.module.sanitize_error_details( - 'Bearer abcdefg refresh_token=xyz password: "123456"' - ) - self.assertIn("Bearer ***", sanitized) - self.assertIn("refresh_token=***", sanitized) - self.assertIn( - '"123456"', 'Bearer abcdefg refresh_token=xyz password: "123456"' - ) # sanity - self.assertNotIn("123456", sanitized) - - def test_build_error_payload_sanitizes_message_and_details(self): - payload = self.module.build_error_payload( - "TEST", - 'Bearer abcdefg refresh_token=xyz password: "123456"', - "TestError", - 400, - 'Bearer abcdefg refresh_token=xyz password: "123456"', - ) - self.assertIn("Bearer ***", payload.get("message", "")) - self.assertIn("refresh_token=***", payload.get("message", "")) - self.assertNotIn("abcdefg", payload.get("message", "")) - self.assertNotIn("123456", payload.get("message", "")) - - self.assertIn("Bearer ***", payload.get("details", "")) - self.assertIn("refresh_token=***", payload.get("details", "")) - self.assertNotIn("abcdefg", payload.get("details", "")) - self.assertNotIn("123456", payload.get("details", "")) - - def test_delete_emails_all_methods_fail_returns_aggregated_error(self): - client = self.app.test_client() - login = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(login.status_code, 200) - - email_addr = "user@example.com" - graph_error = { - "code": "EMAIL_DELETE_FAILED", - "message": "Graph 删除失败", - "type": "GraphAPIError", - "status": 502, - "details": "graph_failed", - "trace_id": "", - } - - # 控制器现在使用 repositories 和 services,需要 mock 这些模块 - from outlook_web.repositories import accounts as accounts_repo - from outlook_web.services import graph as graph_service - from outlook_web.services import imap as imap_service - - with patch.object( - accounts_repo, - "get_account_by_email", - return_value={ - "email": email_addr, - "client_id": "cid", - "refresh_token": "rt", - "group_id": None, - }, - ), patch.object( - graph_service, - "delete_emails_graph", - return_value={ - "success": False, - "error": graph_error, - "success_count": 0, - "failed_count": 2, - "errors": ["e1"], - }, - ), patch.object( - imap_service, - "delete_emails_imap", - side_effect=[ - {"success": False, "error": "imap_new_failed"}, - {"success": False, "error": "imap_old_failed"}, - ], - ): - resp = client.post( - "/api/emails/delete", - json={"email": email_addr, "ids": ["m1", "m2"]}, - ) - - self.assertEqual(resp.status_code, 502) - data = resp.get_json() - self.assertEqual(data.get("success"), False) - self.assertIsInstance(data.get("error"), dict) - self.assertEqual(data["error"].get("code"), "EMAIL_DELETE_ALL_METHODS_FAILED") - self.assertEqual(data["error"].get("status"), 502) - self.assertTrue(data["error"].get("trace_id")) - self.assertEqual(resp.headers.get("X-Trace-Id"), data["error"].get("trace_id")) - - details_text = data["error"].get("details") or "" - self.assertIn("Graph API", details_text) - self.assertIn("IMAP(新服务器)", details_text) - self.assertIn("IMAP(旧服务器)", details_text) - - def test_delete_emails_proxy_error_does_not_fallback_to_imap(self): - client = self.app.test_client() - login = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(login.status_code, 200) - - email_addr = "user@example.com" - graph_res = { - "success": False, - "error": { - "code": "EMAIL_DELETE_FAILED", - "message": "代理连接失败", - "type": "ProxyError", - "status": 502, - "details": "proxy_failed", - "trace_id": "", - }, - "success_count": 0, - "failed_count": 2, - "errors": ["ProxyError: connect failed"], - } - - # 控制器现在使用 repositories 和 services,需要 mock 这些模块 - from outlook_web.repositories import accounts as accounts_repo - from outlook_web.services import graph as graph_service - from outlook_web.services import imap as imap_service - - with patch.object( - accounts_repo, - "get_account_by_email", - return_value={ - "email": email_addr, - "client_id": "cid", - "refresh_token": "rt", - "group_id": None, - }, - ), patch.object( - graph_service, - "delete_emails_graph", - return_value=graph_res, - ), patch.object( - imap_service, - "delete_emails_imap", - ) as imap_mock: - resp = client.post( - "/api/emails/delete", - json={"email": email_addr, "ids": ["m1", "m2"]}, - ) - imap_mock.assert_not_called() - - self.assertEqual(resp.status_code, 502) - data = resp.get_json() - self.assertEqual(data.get("success"), False) - self.assertIsInstance(data.get("error"), dict) - self.assertEqual(data["error"].get("type"), "ProxyError") - self.assertTrue(data["error"].get("trace_id")) - self.assertEqual(resp.headers.get("X-Trace-Id"), data["error"].get("trace_id")) - - def test_legacy_error_string_is_normalized_to_structured_error(self): - client = self.app.test_client() - - # 登录 - login = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(login.status_code, 200) - self.assertEqual(login.get_json().get("success"), True) - - # 触发一个 legacy 的字符串错误(现在应返回正确的 400 状态码) - resp = client.get("/api/groups/999999") - self.assertEqual(resp.status_code, 400) - data = resp.get_json() - self.assertEqual(data.get("success"), False) - self.assertIsInstance(data.get("error"), dict) - self.assertEqual(data["error"].get("code"), "LEGACY_ERROR") - self.assertEqual(data["error"].get("message"), "分组不存在") - self.assertTrue(data["error"].get("trace_id")) - - # 响应体应是 JSON(确保 after_request 的 set_data 没破坏格式) - json.loads(resp.get_data(as_text=True)) - - def test_scheduler_status_endpoint_is_accessible_after_login(self): - client = self.app.test_client() - login = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(login.status_code, 200) - - resp = client.get("/api/scheduler/status") - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("success"), True) - self.assertIn("scheduler", data) - self.assertIn("refresh", data) - - def test_validate_cron_endpoint(self): - client = self.app.test_client() - login = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(login.status_code, 200) - - resp = client.post( - "/api/settings/validate-cron", json={"cron_expression": "0 2 * * *"} - ) - data = resp.get_json() - self.assertEqual(resp.status_code, 200) - self.assertEqual(data.get("success"), True) - self.assertEqual(data.get("valid"), True) - self.assertIsInstance(data.get("future_runs"), list) - self.assertGreaterEqual(len(data.get("future_runs")), 1) - - def test_system_diagnostics_includes_schema(self): - client = self.app.test_client() - login = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(login.status_code, 200) - - resp = client.get("/api/system/diagnostics") - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("success"), True) - diagnostics = data.get("diagnostics") or {} - self.assertIn("schema", diagnostics) - self.assertIn("version", diagnostics["schema"]) - self.assertIn("target_version", diagnostics["schema"]) - self.assertIn("up_to_date", diagnostics["schema"]) - - def test_system_upgrade_status_endpoint(self): - client = self.app.test_client() - login = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(login.status_code, 200) - - resp = client.get("/api/system/upgrade-status") - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("success"), True) - upgrade = data.get("upgrade") or {} - self.assertIn("schema_version", upgrade) - self.assertIn("target_version", upgrade) - self.assertIn("up_to_date", upgrade) - self.assertIn("backup_hint", upgrade) - - def test_login_rate_limiting_locks_after_max_attempts(self): - client = self.app.test_client() - ip = "10.0.0.99" - - for _ in range(self.module.MAX_LOGIN_ATTEMPTS): - resp = client.post( - "/login", - json={"password": "wrong_password"}, - headers={"X-Forwarded-For": ip}, - ) - self.assertEqual(resp.status_code, 401) - data = resp.get_json() - self.assertEqual(data.get("success"), False) - self.assertIsInstance(data.get("error"), dict) - self.assertEqual(data["error"].get("code"), "LOGIN_INVALID_PASSWORD") - - resp = client.post( - "/login", - json={"password": "wrong_password"}, - headers={"X-Forwarded-For": ip}, - ) - self.assertEqual(resp.status_code, 429) - data = resp.get_json() - self.assertEqual(data.get("success"), False) - self.assertIsInstance(data.get("error"), dict) - self.assertEqual(data["error"].get("code"), "LOGIN_RATE_LIMITED") - self.assertEqual(data["error"].get("status"), 429) - self.assertTrue(data["error"].get("trace_id")) - - def test_export_requires_verify_token(self): - client = self.app.test_client() - login = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(login.status_code, 200) - - resp = client.post( - "/api/accounts/export-selected", - json={"group_ids": [1], "verify_token": "invalid_token"}, - ) - self.assertEqual(resp.status_code, 401) - data = resp.get_json() - self.assertEqual(data.get("success"), False) - self.assertEqual(data.get("need_verify"), True) - self.assertIsInstance(data.get("error"), dict) - self.assertEqual(data["error"].get("status"), 401) - self.assertTrue(data["error"].get("trace_id")) - - def test_accounts_list_and_search_include_tags_and_last_refresh(self): - client = self.app.test_client() - login = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(login.status_code, 200) - - unique = uuid.uuid4().hex - email_addr = f"test_{unique}@example.com" - client_id = "client_id_" + unique - refresh_token = "rt_" + unique - tag_name = "tag_" + unique - - conn = self.module.create_sqlite_connection() - try: - # 插入账号 - cur = conn.execute( - """ - INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - (email_addr, "", client_id, refresh_token, 1, "remark", "active"), - ) - account_id = cur.lastrowid - - # 插入标签并关联 - cur = conn.execute( - "INSERT INTO tags (name, color) VALUES (?, ?)", - (tag_name, "#111111"), - ) - tag_id = cur.lastrowid - conn.execute( - "INSERT OR IGNORE INTO account_tags (account_id, tag_id) VALUES (?, ?)", - (account_id, tag_id), - ) - - # 插入刷新日志(用于 last_refresh_status/error) - conn.execute( - """ - INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message) - VALUES (?, ?, ?, ?, ?) - """, - (account_id, email_addr, "manual", "failed", "network_error"), - ) - - conn.commit() - finally: - conn.close() - - # 列表接口:应返回 tags + last_refresh_* - resp = client.get("/api/accounts") - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("success"), True) - - accounts = data.get("accounts") or [] - target = next((a for a in accounts if a.get("email") == email_addr), None) - self.assertIsNotNone(target) - self.assertTrue(target.get("client_id", "").startswith(client_id[:8])) - self.assertEqual(target.get("last_refresh_status"), "failed") - self.assertEqual(target.get("last_refresh_error"), "network_error") - self.assertIsInstance(target.get("tags"), list) - self.assertIn(tag_name, [t.get("name") for t in target.get("tags")]) - - # 搜索接口:按标签名应可搜索到 - resp = client.get(f"/api/accounts/search?q={tag_name}") - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("success"), True) - accounts = data.get("accounts") or [] - target = next((a for a in accounts if a.get("email") == email_addr), None) - self.assertIsNotNone(target) - self.assertEqual(target.get("last_refresh_status"), "failed") - self.assertIn(tag_name, [t.get("name") for t in target.get("tags")]) - - def test_system_group_cannot_be_deleted(self): - client = self.app.test_client() - login = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(login.status_code, 200) - - conn = self.module.create_sqlite_connection() - try: - row = conn.execute( - "SELECT id FROM groups WHERE is_system = 1 LIMIT 1" - ).fetchone() - self.assertIsNotNone(row) - system_group_id = row["id"] - finally: - conn.close() - - resp = client.delete(f"/api/groups/{system_group_id}") - self.assertEqual(resp.status_code, 403) - data = resp.get_json() - self.assertEqual(data.get("success"), False) - self.assertIsInstance(data.get("error"), dict) - self.assertEqual(data["error"].get("code"), "SYSTEM_GROUP_PROTECTED") - self.assertEqual(data["error"].get("status"), 403) - - def test_account_cannot_be_moved_to_system_group_via_update(self): - client = self.app.test_client() - login = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(login.status_code, 200) - - unique = uuid.uuid4().hex - email_addr = f"move_{unique}@example.com" - - conn = self.module.create_sqlite_connection() - try: - system_row = conn.execute( - "SELECT id FROM groups WHERE is_system = 1 LIMIT 1" - ).fetchone() - self.assertIsNotNone(system_row) - system_group_id = system_row["id"] - - cur = conn.execute( - """ - INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - (email_addr, "", "client", "rt", 1, "", "active"), - ) - account_id = cur.lastrowid - conn.commit() - finally: - conn.close() - - resp = client.put( - f"/api/accounts/{account_id}", - json={"email": email_addr, "group_id": system_group_id}, - ) - self.assertEqual(resp.status_code, 403) - data = resp.get_json() - self.assertEqual(data.get("success"), False) - self.assertIsInstance(data.get("error"), dict) - self.assertEqual(data["error"].get("code"), "SYSTEM_GROUP_PROTECTED") - self.assertEqual(data["error"].get("status"), 403) diff --git a/tests/test_frontend_manual.py b/tests/test_frontend_manual.py deleted file mode 100644 index ff3a931..0000000 --- a/tests/test_frontend_manual.py +++ /dev/null @@ -1,541 +0,0 @@ -""" -前端功能测试说明文档 - -由于前端测试需要在浏览器环境中运行,这里提供测试用例的详细说明和手动测试步骤。 -如果需要自动化测试,可以使用 Selenium、Playwright 或 Cypress 等工具。 - -本文档包含: -- 全选功能测试(7 个用例) -- 复制功能测试(5 个用例) -- 轮询功能测试(6 个用例) -- 设置界面测试(3 个用例) -""" - -# ==================== 全选功能测试 ==================== - - -class FrontendSelectAllTests: - """ - 全选功能测试用例集 - - 测试前置条件: - 1. 前端页面已加载 - 2. 已登录系统 - 3. 当前分组有至少 5 个邮箱 - """ - - def test_ft_001_select_all_accounts(self): - """ - 测试用例 FT-001:全选所有邮箱 - - 测试步骤: - 1. 打开邮箱管理页面 - 2. 选择一个分组 - 3. 点击全选复选框 - 4. 观察所有邮箱的复选框状态 - - 预期结果: - - 全选复选框显示为选中状态 - - 所有邮箱的复选框都被选中 - - 批量操作栏显示"已选 N 项" - - 验证方法: - ```javascript - // 在浏览器控制台执行 - const selectAllCheckbox = document.getElementById('selectAllAccounts'); - console.log('全选框状态:', selectAllCheckbox.checked); // 应该为 true - - const accountCheckboxes = document.querySelectorAll('.account-checkbox'); - const allChecked = Array.from(accountCheckboxes).every(cb => cb.checked); - console.log('所有邮箱都被选中:', allChecked); // 应该为 true - - const batchBar = document.getElementById('batchActionBar'); - console.log('批量操作栏显示:', batchBar.style.display !== 'none'); // 应该为 true - ``` - """ - pass - - def test_ft_002_unselect_all(self): - """ - 测试用例 FT-002:取消全选 - - 测试步骤: - 1. 在全选状态下 - 2. 再次点击全选复选框 - 3. 观察所有邮箱的复选框状态 - - 预期结果: - - 全选复选框显示为未选中状态 - - 所有邮箱的复选框都被取消选中 - - 批量操作栏隐藏 - - 验证方法: - ```javascript - const selectAllCheckbox = document.getElementById('selectAllAccounts'); - console.log('全选框状态:', selectAllCheckbox.checked); // 应该为 false - - const accountCheckboxes = document.querySelectorAll('.account-checkbox'); - const noneChecked = Array.from(accountCheckboxes).every(cb => !cb.checked); - console.log('所有邮箱都未选中:', noneChecked); // 应该为 true - ``` - """ - pass - - def test_ft_0erminate_state(self): - """ - 测试用例 FT-003:半选状态 - - 测试步骤: - 1. 手动选中部分邮箱(不是全部) - 2. 观察全选复选框状态 - - 预期结果: - - 全选复选框显示为半选状态(indeterminate) - - 批量操作栏显示"已选 N 项" - - 验证方法: - ```javascript - const selectAllCheckbox = document.getElementById('selectAllAccounts'); - console.log('半选状态:', selectAllCheckbox.indeterminate); // 应该为 true - console.log('选中状态:', selectAllCheckbox.checked); // 应该为 false - ``` - """ - pass - - def test_ft_004_batch_move_group(self): - """ - 测试用例 FT-004:全选后批量移动分组 - - 测试步骤: - 1. 点击全选复选框 - 2. 点击批量操作栏的"移动分组"按钮 - 3. 选择目标分组 - 4. 确认移动 - - 预期结果: - - 所有邮箱成功移动到目标分组 - - 显示成功提示 - - 当前分组的邮箱列表更新 - """ - pass - - def test_ft_005_batch_add_tags(self): - """ - 测试用例 FT-005:全选后批量打标签 - - 测试步骤: - 1. 点击全选复选框 - 2. 点击批量操作栏的"打标签"按钮 - 3. 选择标签 - - 预期结果: - - 所有邮箱成功添加标签 - - 显示成功提示 - - 邮箱列表显示标签 - """ - pass - - def test_ft_006_reset_on_group_change(self): - """ - 测试用例 FT-006:切换分组重置全选状态 - - 测试步骤: - 1. 选中部分或全部邮箱 - 2. 切换到其他分组 - 3. 观察全选复选框状态 - - 预期结果: - - 全选复选框显示为未选中状态 - - 批量操作栏隐藏 - - 验证方法: - ```javascript - // 切换分组后 - const selectAllCheckbox = document.getElementById('selectAllAccounts'); - console.log('全选框状态:', selectAllCheckbox.checked); // 应该为 false - console.log('半选状态:', selectAllCheckbox.indeterminate); // 应该为 false - ``` - """ - pass - - def test_ft_007_select_filtered_only(self): - """ - 测试用例 FT-007:搜索后全选只影响显示的邮箱 - - 测试步骤: - 1. 在搜索框输入关键词,过滤部分邮箱 - 2. 点击全选复选框 - 3. 观察选中数量 - - 预期结果: - - 只有当前显示的邮箱被选中 - - 批量操作栏显示的数量等于显示的邮箱数量 - - 验证方法: - ```javascript - const visibleAccounts = document.querySelectorAll('.account-item:not([style*="display: none"])'); - const selectedCount = selectedAccounts.size; - console.log('显示的邮箱数:', visibleAccounts.length); - console.log('选中的邮箱数:', selectedCount); - console.log('数量一致:', visibleAccounts.length === selectedCount); // 应该为 true - ``` - """ - pass - - -# ==================== 复制功能测试 ==================== - - -class FrontendCopyTests: - """ - 复制功能测试用例集 - - 测试前置条件: - 1. 前端页面已加载 - 2. 已登录系统 - 3. 测试邮箱有邮件数据 - """ - - def test_ft_008_copy_verification_success(self): - """ - 测试用例 FT-008:复制验证码成功 - - 测试步骤: - 1. 找到测试邮箱 - 2. 点击复制按钮 - 3. 等待加载完成 - 4. 粘贴到文本框验证 - - 预期结果: - - 显示"已复制"通知 - - 复制按钮显示绿色对勾(1秒后恢复) - - 粘贴的内容包含验证码 - - 验证方法: - 1. 点击复制按钮 - 2. 在任意文本框按 Ctrl+V 粘贴 - 3. 检查粘贴的内容是否包含验证码 - """ - pass - - def test_ft_009_copy_code_and_links(self): - """ - 测试用例 FT-009:复制验证码和链接 - - 测试步骤: - 1. 找到测试邮箱(邮件同时包含验证码和链接) - 2. 点击复制按钮 - 3. 等待加载完成 - 4. 粘贴到文本框验证 - - 预期结果: - - 显示"已复制"通知 - - 粘贴的内容格式为"验证码 链接1 链接2"(空格分隔) - - 验证方法: - ```javascript - // 粘贴后检查格式 - const pastedText = "999888 https://example.com/activate"; - const parts = pastedText.split(' '); - console.log('验证码:', parts[0]); // 999888 - console.log('链接:', parts.slice(1)); // ["https://example.com/activate"] - ``` - """ - pass - - def test_ft_010_not_found_verification(self): - """ - 测试用例 FT-010:未找到验证信息 - - 测试步骤: - 1. 找到测试邮箱(邮件不包含验证码和链接) - 2. 点击复制按钮 - 3. 等待加载完成 - - 预期结果: - - 显示"未找到验证信息"提示 - - 复制按钮恢复正常状态 - """ - pass - - def test_ft_011_auto_fetch_emails(self): - """ - 测试用例 FT-011:邮箱无数据取 - - 测试步骤: - 1. 找到未获取过邮件的测试邮箱 - 2. 点击复制按钮 - 3. 观察页面行为 - - 预期结果: - - 显示"正在获取邮件..."提示 - - 自动调用获取邮件接口 - - 获取成功后自动复制验证信息 - - 验证方法: - 打开浏览器开发者工具 Network 标签,观察是否有: - 1. GET /api/emails/ 请求 - 2. GET /api/emails//extract-verification 请求 - """ - pass - - def test_ft_012_button_loading_state(self): - """ - 测试用例 FT-012:复制按钮加载状态 - - 测试步骤: - 1. 点击复制按钮 - 2. 观察按钮状态变化 - - 预期结果: - - 按钮立即变为禁用状态 - - 显示加载动画 - - 加载完成后恢复正常状态 - - 验证方法: - ```javascript - const btn = document.querySelector('.copy-verification-btn'); - console.log('按钮禁用:', btn.disabled); // 加载时应该为 true - console.log('加载样式:', btn.classList.contains('loading')); // 加载时应该为 true - ``` - """ - pass - - -# ==================== 轮询功能测试 ==================== - - -class FrontendPollingTests: - """ - 轮询功能测试用例集 - - 测试前置条件: - 1. 前端页面已加载 - 2. 已登录系统 - 3. 在设置中启用了自动轮询 - """ - - def test_ft_013_auto_start_polling(self): - """ - 测试用例 FT-013:启用轮询后自动开始 - - 测试步骤: - 1. 打开设置,启用自动轮询 - 2. 设置轮询间隔为 10 秒,次数为 3 次 - 3. 保存设置 - 4. 选中一个邮箱 - 5. 等待观察 - - 预期结果: - - 每 10 秒自动获取一次邮件 - - 总共轮询 3 次后自动停止 - - 控制台输出轮询日志 - - 验证方法: - 打开浏览器控制台,观察日志输出: - ``` - 开始轮询邮箱: test@example.com, 间隔: 10000ms, 次数: 3 - 轮询第 1/3 次 - 轮询第 2/3 次 - 轮询第 3/3 次 - 轮询已完成 - ``` - """ - pass - - def test_ft_014_new_email_notification(self): - """ - 测试用例 FT-014:检测到新邮件显示通知 - - 测试步骤: - 1. 在轮询过程中,向测试邮箱发送新邮件 - 2. 等待下一次轮询 - 3. 观察页面通知 - - 预期结果: - - 右上角显示通知:"邮箱地址 收到新邮件:邮件主题" - - 通知 5 秒后自动消失 - - 邮箱列表显示红点提示 - """ - pass - - def test_ft_015_new_email_badge(self): - """ - 测试用例 FT-015:邮箱列表红点提示 - - 测试步骤: - 1. 观察有新邮件的邮箱 - 2. 点击该邮箱查看邮件 - 3. 观察红点状态 - - 预期结果: - - 邮箱名称旁边显示红色圆点 - - 点击邮箱后红点消失 - - 验证方法: - ```javascript - const badge = document.querySelector('.new-email-badge'); - console.log('红点存在:', badge !== null); // 应该为 true - - // 点击邮箱后 - console.log('红点消失:', document.querySelector('.new-email-badge') === null); // 应该为 true - ``` - """ - pass - - def test_ft_016_stop_on_account_change(self): - """ - 测试用例 FT-016:切换邮箱停止轮询 - - 测试步骤: - 1. 在轮询过程中 - 2. 切换到其他邮箱 - 3. 观察控制台日志 - - 预期结果: - - 当前轮询立即停止 - - 控制台输出"停止轮询"日志 - - 开始轮询新选中的邮箱 - """ - pass - - def test_ft_017_stop_on_error(self): - """ - 测试用例 FT-017:轮询失败自动停止 - - 测试步骤: - 1. 在轮询过程中 - 2. 模拟网络错误(断开网络或使用开发者工具模拟) - 3. 观察页面行为 - - 预期结果: - - 轮询立即停止 - - 显示错误提示:"轮询失败,已自动停止" - - 控制台输出错误日志 - """ - pass - - def test_ft_018_close_notification(self): - """ - 测试用例 FT-018:通知点击关闭 - - 测试步骤: - 1. 等待新邮件通知显示 - 2. 点击通知的关闭按钮(×) - - 预期结果: - - 通知立即消失 - """ - pass - - -# ==================== 设置界面测试 ==================== - - -class FrontendSettingsTests: - """ - 设置界面测试用例集 - - 测试前置条件: - 1. 前端页面已加载 - 2. 已登录系统 - """ - - def test_ft_019_load_polling_settings(self): - """ - 测试用例 FT-019:加载轮询配置 - - 测试步骤: - 1. 打开设置页面 - 2. 观察轮询配置项 - - 预期结果: - - 显示当前的轮询开关状态 - - 显示当前的轮询间隔 - - 显示当前的轮询次数 - - 验证方法: - ```javascript - const enableCheckbox = document.getElementById('enableAutoPolling'); - const intervalInput = document.getElementById('pollingInterval'); - const countInput = document.getElementById('pollingCount'); - - console.log('轮询开关:', enableCheckbox.checked); - console.log('轮询间隔:', intervalInput.value); - console.log('轮询次数:', countInput.value); - ``` - """ - pass - - def test_ft_020_save_polling_settings(self): - """ - 测试用例 FT-020:保存轮询配置 - - 测试步骤: - 1. 修改轮询配置 - 2. 点击保存按钮 - 3. 观察提示信息 - - 预期结果: - - 显示"设置已保存,下次选中邮箱时生效"提示 - - 刷新页面后配置保持 - """ - pass - - def test_ft_021_interval_input_validation(self): - """ - 测试用例 FT-021:轮询间隔输入验证 - - 测试步骤: - 1. 尝试输入小于 5 的值 - 2. 尝试输入大于 300 的值 - 3. 点击保存 - - 预期结果: - - 输入框显示验证错误 - - 或保存时显示错误提示 - - 验证方法: - ```javascript - const intervalInput = document.getElementById('pollingInterval'); - intervalInput.value = 3; - // 触发验证 - intervalInput.dispatchEvent(new Event('change')); - - // 检查验证状态 - console.log('验证失败:', intervalInput.validity.valid === false); - ``` - """ - pass - - -# ==================== 测试执行说明 ==================== - -""" -前端测试执行说明: - -1. 手动测试: - - 按照每个测试用例的步骤逐一执行 - - 记录实际结果 - - 对比预期结果,判断是否通过 - -2. 自动化测试(可选): - - 使用 Selenium WebDriver - - 使用 Playwright - - 使用 Cypress - -3. 测试数据准备: - - 创建测试邮箱账号 - - 准备测试邮件(包含不同格式的验证码) - - 创建测试分组和标签 - -4. 测试环境: - - Chrome 最新版(推荐) - - Firefox 最新版 - - Edge 最新版 - -5. 测试报告: - - 记录每个测试用例的执行结果 - - 截图保存关键步骤 - - 记录发现的问题 -""" diff --git a/tests/test_gptmail_error_handling.py b/tests/test_gptmail_error_handling.py deleted file mode 100644 index 79cc28d..0000000 --- a/tests/test_gptmail_error_handling.py +++ /dev/null @@ -1,178 +0,0 @@ -import unittest -from unittest.mock import patch, MagicMock -import requests - -from outlook_web.services import gptmail - - -class TestGPTMailErrorHandling(unittest.TestCase): - """测试 GPTMail 服务的错误处理""" - - def test_gptmail_request_no_api_key(self): - """测试 API Key 未配置的情况""" - with patch("outlook_web.services.gptmail.get_gptmail_api_key", return_value=""): - result = gptmail.gptmail_request("GET", "/api/test") - - self.assertFalse(result["success"]) - self.assertEqual(result["error_type"], "CONFIG_ERROR") - self.assertIn("未配置", result["error"]) - - def test_gptmail_request_401_unauthorized(self): - """测试 API Key 无效的情况""" - mock_response = MagicMock() - mock_response.status_code = 401 - - with patch( - "outlook_web.services.gptmail.get_gptmail_api_key", - return_value="invalid_key", - ): - with patch("requests.get", return_value=mock_response): - result = gptmail.gptmail_request("GET", "/api/test") - - self.assertFalse(result["success"]) - self.assertEqual(result["error_type"], "AUTH_ERROR") - self.assertIn("无效", result["error"]) - - def test_gptmail_request_timeout(self): - """测试请求超时的情况""" - with patch( - "outlook_web.services.gptmail.get_gptmail_api_key", return_value="test_key" - ): - with patch("requests.get", side_effect=requests.exceptions.Timeout()): - result = gptmail.gptmail_request("GET", "/api/test") - - self.assertFalse(result["success"]) - self.assertEqual(result["error_type"], "TIMEOUT_ERROR") - self.assertIn("超时", result["error"]) - - def test_gptmail_request_connection_error(self): - """测试网络连接失败的情况""" - with patch( - "outlook_web.services.gptmail.get_gptmail_api_key", return_value="test_key" - ): - with patch( - "requests.get", - side_effect=requests.exceptions.ConnectionError("Connection refused"), - ): - result = gptmail.gptmail_request("GET", "/api/test") - - self.assertFalse(result["success"]) - self.assertEqual(result["error_type"], "CONNECTION_ERROR") - self.assertIn("连接", result["error"]) - - def test_gptmail_request_500_server_error(self): - """测试服务器错误的情况""" - mock_response = MagicMock() - mock_response.status_code = 500 - - with patch( - "outlook_web.services.gptmail.get_gptmail_api_key", return_value="test_key" - ): - with patch("requests.get", return_value=mock_response): - result = gptmail.gptmail_request("GET", "/api/test") - - self.assertFalse(result["success"]) - self.assertEqual(result["error_type"], "SERVER_ERROR") - self.assertIn("不可用", result["error"]) - - def test_gptmail_request_429_rate_limit(self): - """测试请求频率超限的情况""" - mock_response = MagicMock() - mock_response.status_code = 429 - - with patch( - "outlook_web.services.gptmail.get_gptmail_api_key", return_value="test_key" - ): - with patch("requests.get", return_value=mock_response): - result = gptmail.gptmail_request("GET", "/api/test") - - self.assertFalse(result["success"]) - self.assertEqual(result["error_type"], "RATE_LIMIT_ERROR") - self.assertIn("频率超限", result["error"]) - - def test_generate_temp_email_success(self): - """测试成功生成临时邮箱""" - mock_result = {"success": True, "data": {"email": "test@example.com"}} - - with patch( - "outlook_web.services.gptmail.gptmail_request", return_value=mock_result - ): - email, error = gptmail.generate_temp_email() - - self.assertEqual(email, "test@example.com") - self.assertIsNone(error) - - def test_generate_temp_email_api_key_not_configured(self): - """测试 API Key 未配置时生成临时邮箱""" - mock_result = { - "success": False, - "error": "GPTMail API Key 未配置", - "error_type": "CONFIG_ERROR", - "details": "请在系统设置中配置 GPTMail API Key", - } - - with patch( - "outlook_web.services.gptmail.gptmail_request", return_value=mock_result - ): - email, error = gptmail.generate_temp_email() - - self.assertIsNone(email) - self.assertIn("未配置", error) - self.assertIn("系统设置", error) - - def test_generate_temp_email_timeout(self): - """测试请求超时时生成临时邮箱""" - mock_result = { - "success": False, - "error": "API 请求超时", - "error_type": "TIMEOUT_ERROR", - "details": "请求超过 30 秒未响应,请检查网络连接或稍后重试", - } - - with patch( - "outlook_web.services.gptmail.gptmail_request", return_value=mock_result - ): - email, error = gptmail.generate_temp_email() - - self.assertIsNone(email) - self.assertIn("超时", error) - self.assertIn("30 秒", error) - - def test_generate_temp_email_missing_email_field(self): - """测试 API 返回数据缺少 email 字段""" - mock_result = {"success": True, "data": {}} # 缺少 email 字段 - - with patch( - "outlook_web.services.gptmail.gptmail_request", return_value=mock_result - ): - email, error = gptmail.generate_temp_email() - - self.assertIsNone(email) - self.assertIn("格式错误", error) - self.assertIn("email", error) - - def test_generate_temp_email_with_prefix_and_domain(self): - """测试使用 prefix 和 domain 生成临时邮箱""" - mock_result = {"success": True, "data": {"email": "custom@example.com"}} - - with patch( - "outlook_web.services.gptmail.gptmail_request", return_value=mock_result - ) as mock_request: - email, error = gptmail.generate_temp_email( - prefix="custom", domain="example.com" - ) - - self.assertEqual(email, "custom@example.com") - self.assertIsNone(error) - - # 验证调用参数 - mock_request.assert_called_once() - call_args = mock_request.call_args - self.assertEqual(call_args[0][0], "POST") - self.assertEqual( - call_args[1]["json_data"], {"prefix": "custom", "domain": "example.com"} - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_masking_audit_and_import.py b/tests/test_masking_audit_and_import.py deleted file mode 100644 index e5152ed..0000000 --- a/tests/test_masking_audit_and_import.py +++ /dev/null @@ -1,272 +0,0 @@ -import unittest -import uuid -from unittest.mock import patch - -from tests._import_app import import_web_app_module, clear_login_attempts - - -class MaskingAuditAndImportTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.module = import_web_app_module() - cls.app = cls.module.app - - def setUp(self): - # 每个测试前清理登录限制记录,避免测试间互相影响 - with self.app.app_context(): - clear_login_attempts() - - def _login(self, client): - resp = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.get_json().get("success"), True) - - def _default_group_id(self) -> int: - conn = self.module.create_sqlite_connection() - try: - row = conn.execute( - "SELECT id FROM groups WHERE name = '默认分组' LIMIT 1" - ).fetchone() - return row["id"] if row else 1 - finally: - conn.close() - - def test_settings_get_does_not_leak_secrets(self): - client = self.app.test_client() - self._login(client) - - resp = client.get("/api/settings") - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("success"), True) - - settings = data.get("settings") or {} - self.assertNotIn("login_password", settings) - self.assertNotIn("gptmail_api_key", settings) - self.assertIn("login_password_set", settings) - self.assertIn("gptmail_api_key_set", settings) - - def test_account_get_does_not_return_password_or_refresh_token(self): - unique = uuid.uuid4().hex - email_addr = f"mask_{unique}@example.com" - password = f"pass_{unique}" - refresh_token = f"rt_{unique}" - client_id = f"cid_{unique}" - - conn = self.module.create_sqlite_connection() - try: - cur = conn.execute( - """ - INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - email_addr, - password, - client_id, - refresh_token, - self._default_group_id(), - "", - "active", - ), - ) - account_id = cur.lastrowid - conn.commit() - finally: - conn.close() - - client = self.app.test_client() - self._login(client) - resp = client.get(f"/api/accounts/{account_id}") - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("success"), True) - - account = data.get("account") or {} - self.assertEqual(account.get("id"), account_id) - self.assertEqual(account.get("email"), email_addr) - self.assertEqual(account.get("password"), "") - self.assertEqual(account.get("refresh_token"), "") - self.assertEqual(account.get("has_password"), True) - self.assertEqual(account.get("has_refresh_token"), True) - - body_text = resp.get_data(as_text=True) - self.assertNotIn(password, body_text) - self.assertNotIn(refresh_token, body_text) - - def test_system_group_cannot_be_renamed(self): - client = self.app.test_client() - self._login(client) - - conn = self.module.create_sqlite_connection() - try: - row = conn.execute( - "SELECT id, name FROM groups WHERE is_system = 1 LIMIT 1" - ).fetchone() - self.assertIsNotNone(row) - system_group_id = row["id"] - system_group_name = row["name"] - finally: - conn.close() - - resp = client.put( - f"/api/groups/{system_group_id}", - json={ - "name": system_group_name + "_new", - "description": "x", - "color": "#111111", - }, - ) - self.assertEqual(resp.status_code, 403) - data = resp.get_json() - self.assertEqual(data.get("success"), False) - self.assertIsInstance(data.get("error"), dict) - self.assertEqual(data["error"].get("code"), "SYSTEM_GROUP_PROTECTED") - self.assertEqual(data["error"].get("status"), 403) - - def test_batch_import_returns_line_errors_and_parses_refresh_token_with_delimiter( - self, - ): - client = self.app.test_client() - self._login(client) - - unique = uuid.uuid4().hex - email_addr = f"import_{unique}@example.com" - client_id = f"client_{unique}" - refresh_token = f"rt_{unique}----tail" - - account_string = "\n".join( - [ - "bad_line_without_delimiter", - f"{email_addr}----p----{client_id}----{refresh_token}", - ] - ) - - resp = client.post( - "/api/accounts", - json={ - "account_string": account_string, - "group_id": self._default_group_id(), - }, - ) - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("success"), True) - - summary = data.get("summary") or {} - self.assertEqual(summary.get("imported"), 1) - self.assertEqual(summary.get("failed"), 1) - self.assertEqual(summary.get("total_lines"), 2) - - errors = data.get("errors") or [] - self.assertGreaterEqual(len(errors), 1) - self.assertEqual(errors[0].get("line"), 1) - - conn = self.module.create_sqlite_connection() - try: - row = conn.execute( - "SELECT refresh_token FROM accounts WHERE email = ? LIMIT 1", - (email_addr,), - ).fetchone() - self.assertIsNotNone(row) - stored = row["refresh_token"] - finally: - conn.close() - - stored_plain = self.module.decrypt_data(stored) if stored else stored - self.assertEqual(stored_plain, refresh_token) - - def test_audit_logs_endpoint_includes_group_create(self): - client = self.app.test_client() - self._login(client) - - unique = uuid.uuid4().hex - group_name = f"审计测试_{unique}" - create = client.post( - "/api/groups", - json={ - "name": group_name, - "description": "", - "color": "#222222", - "proxy_url": "", - }, - ) - self.assertEqual(create.status_code, 200) - created = create.get_json() - self.assertEqual(created.get("success"), True) - group_id = created.get("group_id") - self.assertIsInstance(group_id, int) - - resp = client.get("/api/audit-logs?action=create&resource_type=group&limit=200") - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("success"), True) - - logs = data.get("logs") or [] - matched = [ - r - for r in logs - if str(r.get("resource_id")) == str(group_id) - and group_name in (r.get("details") or "") - ] - self.assertTrue(matched) - self.assertTrue(matched[0].get("trace_id")) - - def test_temp_email_actions_are_audited(self): - client = self.app.test_client() - self._login(client) - - unique = uuid.uuid4().hex - email_addr = f"tmp_{unique}@example.com" - - # Mock generate_temp_email 返回元组 (email_addr, None) - # 注意:控制器现在直接使用 gptmail service,需要 mock outlook_web.services.gptmail - from outlook_web.services import gptmail as gptmail_service - - with patch.object( - gptmail_service, "generate_temp_email", return_value=(email_addr, None) - ): - resp = client.post("/api/temp-emails/generate", json={}) - self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.get_json().get("success"), True) - - create_audit = client.get( - "/api/audit-logs?resource_type=temp_email&action=create&limit=200" - ) - self.assertEqual(create_audit.status_code, 200) - create_data = create_audit.get_json() - self.assertEqual(create_data.get("success"), True) - create_logs = create_data.get("logs") or [] - self.assertTrue([r for r in create_logs if r.get("resource_id") == email_addr]) - - with patch.object( - gptmail_service, "clear_temp_emails_from_api", return_value=True - ): - conn = self.module.create_sqlite_connection() - try: - conn.execute( - """ - INSERT INTO temp_email_messages (message_id, email_address, subject, content, timestamp) - VALUES (?, ?, ?, ?, ?) - """, - ("msg_" + unique, email_addr, "s", "c", 1), - ) - conn.commit() - finally: - conn.close() - - clear_resp = client.delete(f"/api/temp-emails/{email_addr}/clear") - self.assertEqual(clear_resp.status_code, 200) - self.assertEqual(clear_resp.get_json().get("success"), True) - - audit_resp = client.get( - "/api/audit-logs?resource_type=temp_email_messages&action=delete&limit=200" - ) - self.assertEqual(audit_resp.status_code, 200) - audit = audit_resp.get_json() - self.assertEqual(audit.get("success"), True) - - logs = audit.get("logs") or [] - matched = [r for r in logs if (r.get("resource_id") == email_addr)] - self.assertTrue(matched) - self.assertTrue(matched[0].get("trace_id")) diff --git a/tests/test_module_boundaries.py b/tests/test_module_boundaries.py deleted file mode 100644 index a91b4b5..0000000 --- a/tests/test_module_boundaries.py +++ /dev/null @@ -1,77 +0,0 @@ -import ast -import unittest -from pathlib import Path - - -class ModuleBoundaryTests(unittest.TestCase): - @staticmethod - def _collect_import_modules(path: Path) -> set[str]: - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - modules: set[str] = set() - - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - modules.add(alias.name) - elif isinstance(node, ast.ImportFrom): - if node.module: - modules.add(node.module) - return modules - - def _assert_forbidden_imports( - self, *, path: Path, forbidden_prefixes: tuple[str, ...] - ): - imports = self._collect_import_modules(path) - for module in sorted(imports): - for prefix in forbidden_prefixes: - if module == prefix or module.startswith(prefix + "."): - self.fail( - f"模块边界违规:{path} 导入了禁止依赖 {module!r}(匹配前缀 {prefix!r})" - ) - - def test_routes_do_not_import_services_repos_or_legacy(self): - repo_root = Path(__file__).resolve().parents[1] - routes_dir = repo_root / "outlook_web" / "routes" - self.assertTrue(routes_dir.exists()) - - forbidden = ( - "outlook_web.legacy", - "outlook_web.services", - "outlook_web.repositories", - "outlook_web.db", - ) - for path in sorted(routes_dir.glob("*.py")): - if path.name == "__init__.py": - continue - self._assert_forbidden_imports(path=path, forbidden_prefixes=forbidden) - - def test_repositories_do_not_depend_on_flask_routes_or_services(self): - repo_root = Path(__file__).resolve().parents[1] - repos_dir = repo_root / "outlook_web" / "repositories" - self.assertTrue(repos_dir.exists()) - - forbidden = ( - "flask", - "outlook_web.routes", - "outlook_web.services", - "outlook_web.legacy", - ) - for path in sorted(repos_dir.glob("*.py")): - if path.name == "__init__.py": - continue - self._assert_forbidden_imports(path=path, forbidden_prefixes=forbidden) - - def test_services_do_not_depend_on_flask_routes_or_legacy(self): - repo_root = Path(__file__).resolve().parents[1] - services_dir = repo_root / "outlook_web" / "services" - self.assertTrue(services_dir.exists()) - - forbidden = ( - "flask", - "outlook_web.routes", - "outlook_web.legacy", - ) - for path in sorted(services_dir.glob("*.py")): - if path.name == "__init__.py": - continue - self._assert_forbidden_imports(path=path, forbidden_prefixes=forbidden) diff --git a/tests/test_performance.py b/tests/test_performance.py deleted file mode 100644 index 8e7a44c..0000000 --- a/tests/test_performance.py +++ /dev/null @@ -1,284 +0,0 @@ -""" -性能测试用例 - -测试批量操作与自动化增强功能的性能指标,包括: -- 全选性能测试(1000 个邮箱 < 100ms) -- 验证码提取 API 性能测试(< 2 秒) -- 轮询性能测试(不影响页面交互) -""" - -import unittest -import sys -import os -import time - -# 添加项目根目录到 Python 路径 -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - - -class TestPerformance(unittest.TestCase): - """性能测试类""" - - def setUp(self): - """测试前置准备""" - pass - - # ==================== 全选性能测试 ==================== - - def test_pt_001_select_all_1000_accounts_performance(self): - """ - 测试用例 PT-001:全选 1000 个邮箱性能 - - 测试目的:验证全选 1000 个邮箱的性能 < 100ms - - 测试方法: - 1. 准备 1000 个测试邮箱数据 - 2. 渲染到页面 - 3. 执行全选操作 - 4. 测量耗时 - - 预期结果:耗时 < 100ms - - 注意:此测试需要在浏览器环境中执行 - """ - # 这是一个前端性能测试,需要在浏览器中执行 - # 测试代码示例(在浏览器控制台执行): - """ - // 准备 1000 个测试邮箱 - const testAccounts = Array.from({length: 1000}, (_, i) => ({ - id: i + 1, - email: `test${i + 1}@example.com`, - group_id: 1 - })); - - // 渲染到页面 - renderAccountList(testAccounts); - - // 测试全选性能 - console.time('全选性能'); - selectAllAccounts(); - console.timeEnd('全选性能'); - - // 预期输出:全选性能: XX.XXms (应该 < 100ms) - """ - - self.assertTrue(True, "前端性能测试,需要在浏览器中执行") - - # ==================== API 性能测试 ==================== - - def test_pt_002_extract_verification_api_performance(self): - """ - 测试用例 PT-002:验证码提取 API 性能 - - 测试目的:验证验证码提取 API 响应时间 < 2 秒 - - 测试方法: - 1. 准备测试邮箱和邮件数据 - 2. 多次调用 API(至少 10 次) - 3. 计算平均响应时间 - - 预期结果:平均响应时间 < 2000ms - """ - # TODO: 实现后取消注释 - # from outlook_web.app import create_app - # - # app = create_app() - # client = app.test_client() - # - # # 登录 - # client.post('/login', data={'password': 'admin123'}) - # - # # 多次测试取平均值 - # times = [] - # for i in range(10): - # start_time = time.time() - # - # response = client.get('/api/emails/test@example.com/extract-verification') - # - # end_time = time.time() - # elapsed_time = (end_time - start_time) * 1000 # 转换为毫秒 - # times.append(elapsed_time) - # - # self.assertEqual(response.status_code, 200) - # - # # 计算平均时间 - # avg_time = sum(times) / len(times) - # max_time = max(times) - # min_time = min(times) - # - # print(f"\n性能测试结果:") - # print(f" 平均响应时间: {avg_time:.2f}ms") - # print(f" 最大响应时间: {max_time:.2f}ms") - # print(f" 最小响应时间: {min_time:.2f}ms") - # - # # 断言平均时间 < 2000ms - # self.assertLess(avg_time, 2000, f"API 平均响应时间过长: {avg_time:.2f}ms") - - self.assertTrue(True, "待实现:API 性能测试") - - # ==================== 轮询性能测试 ==================== - - def test_pt_003_polling_not_block_ui(self): - """ - 测试用例 PT-003:轮询不影响页面交互 - - 测试目的:验证轮询过程中页面交互流畅 - - 测试方法: - 1. 启动轮询 - 2. 在轮询过程中执行各种操作 - 3. 测量操作响应时间 - - 预期结果: - - 页面交互流畅,无卡顿 - - 操作响应及时 - - 注意:此测试需要在浏览器环境中执行 - """ - # 这是一个前端性能测试,需要在浏览器中执行 - # 测试代码示例(在浏览器控制台执行): - """ - // 启动轮询 - startPolling('test@example.com'); - - // 测试各种操作的响应时间 - async function testUIResponsiveness() { - const operations = [ - { - name: '切换分组', - action: () => loadAccountsByGroup(2) - }, - { - name: '搜索邮箱', - action: () => { - const searchInput = document.getElementById('accountSearch'); - searchInput.value = 'test'; - searchInput.dispatchEvent(new Event('input')); - } - }, - { - name: '点击按钮', - tion: () => { - const btn = document.querySelector('.refresh-btn'); - btn.click(); - } - } - ]; - - for (const op of operations) { - console.time(op.name); - await op.action(); - console.timeEnd(op.name); - await new Promise(resolve => setTimeout(resolve, 100)); - } - } - - testUIResponsiveness(); - - // 预期:所有操作响应时间都应该 < 100ms - """ - - self.assertTrue(True, "前端性能测试,需要在浏览器中执行") - - # ==================== 压力测试(可选)==================== - - def test_pt_004_concurrent_api_requests(self): - """ - 测试用例 PT-004:并发 API 请求测试(可选) - - 测试目的:验证 API 能处理并发请求 - - 测试方法: - 1. 同时发送多个 API 请求 - 2. 测量响应时间 - 3. 验证所有请求都成功 - - 预期结果: - - 所有请求都成功返回 - - 平均响应时间仍然 < 2 秒 - """ - # TODO: 实现后取消注释 - # import concurrent.futures - # from outlook_web.app import create_app - # - # app = create_app() - # client = app.test_client() - # - # # 登录 - # client.post('/login', data={'password': 'admin123'}) - # - # def make_request(): - # start_time = time.time() - # response = client.get('/api/emails/test@example.com/extract-verification') - # end_time = time.time() - # return { - # 'status_code': response.status_code, - # 'elapsed_time': (end_time - start_time) * 1000 - # } - # - # # 并发 10 个请求 - # with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: - # futures = [executor.submit(make_request) for _ in range(10)] - # results = [f.result() for f in concurrent.futures.as_completed(futures)] - # - # # 验证结果 - # success_count = sum(1 for r in results if r['status_code'] == 200) - # avg_time = sum(r['elapsed_time'] for r in results) / len(results) - # - # print(f"\n并发测试结果:") - # print(f" 成功请求数: {success_count}/10") - # print(f" 平均响应时间: {avg_time:.2f}ms") - # - # self.assertEqual(success_count, 10, "部分请求失败") - # self.assertLess(avg_time, 2000, f"并发情况下响应时间过长: {avg_time:.2f}ms") - - self.assertTrue(True, "待实现:并发 API 请求测试") - - -class TestMemoryUsage(unittest.TestCase): - """内存使用测试类(可选)""" - - def test_memory_leak_detection(self): - """ - 测试用例:内存泄漏检测 - - 测试目的:验证轮询功能不会导致内存泄漏 - - 测试方法: - 1. 记录初始内存使用 - 2. 执行多次轮询操作 - 3. 记录最终内存使用 - 4. 对比内存增长 - - 预期结果:内存增长在合理范围内 - - 注意:此测试需要在浏览器环境中执行 - """ - # 这是一个前端内存测试,需要在浏览器中执行 - # 测试代码示例(在浏览器控制台执行): - """ - // 使用 Chrome DevTools Memory Profiler - - // 1. 打开 Chrome DevTools -> Memory - // 2. 记录初始堆快照 - // 3. 执行以下代码 - async function testMemoryLeak() { - for (let i = 0; i < 100; i++) { - startPolling('test@example.com'); - await new Promise(resolve => setTimeout(resolve, 1000)); - stopPolling(); - } - } - - testMemoryLeak(); - - // 4. 记录最终堆快照 - // 5. 对比两个快照,检查是否有内存泄漏 - """ - - self.assertTrue(True, "前端内存测试,需要在浏览器中执行") - - -if __name__ == "__main__": - # 运行性能测试 - unittest.main(verbosity=2) diff --git a/tests/test_smoke_contract.py b/tests/test_smoke_contract.py deleted file mode 100644 index d11f925..0000000 --- a/tests/test_smoke_contract.py +++ /dev/null @@ -1,131 +0,0 @@ -import unittest - -from tests._import_app import import_web_app_module, clear_login_attempts - - -class SmokeContractTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.module = import_web_app_module() - cls.app = cls.module.app - - def setUp(self): - # 每个测试前清理登录限制记录,避免测试间互相影响 - with self.app.app_context(): - clear_login_attempts() - - def _login(self, client): - resp = client.post("/login", json={"password": "testpass123"}) - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("success"), True) - - def test_healthz_contract(self): - client = self.app.test_client() - resp = client.get("/healthz") - self.assertEqual(resp.status_code, 200) - data = resp.get_json() - self.assertEqual(data.get("status"), "ok") - - def test_pages_are_accessible(self): - client = self.app.test_client() - - # 登录页(未登录也可访问) - resp = client.get("/login") - self.assertEqual(resp.status_code, 200) - self.assertIn("text/html", (resp.headers.get("Content-Type") or "")) - - # 主页(需要登录) - self._login(client) - resp = client.get("/") - self.assertEqual(resp.status_code, 200) - self.assertIn("text/html", (resp.headers.get("Content-Type") or "")) - html = resp.get_data(as_text=True) - # 前端静态资源应已迁出(零构建):不再内联 style/script - self.assertNotIn("