From 4eafa1cc3f2381ca9438f56811c6e35fdca5740a Mon Sep 17 00:00:00 2001 From: leefer Date: Thu, 30 Jul 2026 11:12:37 +0800 Subject: [PATCH] rebuild(audit): verify page state matrix --- next/docs/evidence/stage-15/acceptance.md | 8 +- next/docs/final/completion-audit.md | 17 +- next/docs/final/page-state-matrix.md | 43 ++++ next/docs/final/spec-coverage.md | 26 +-- .../src/app/system/MembershipAdminPanel.vue | 9 +- .../src/app/system/ModelPoolPanel.vue | 19 +- .../frontend/src/pages/review/AlertsPanel.vue | 5 +- .../src/pages/review/AssistantPanel.vue | 9 +- next/tests/e2e/stage11.spec.js | 28 +++ next/tests/e2e/stage14-matrix.spec.js | 214 ++++++++++++++++++ next/tests/e2e/stage4.spec.js | 13 ++ next/tests/e2e/stage5.spec.js | 20 +- next/tests/e2e/stage6.spec.js | 12 + 13 files changed, 386 insertions(+), 37 deletions(-) create mode 100644 next/docs/final/page-state-matrix.md create mode 100644 next/tests/e2e/stage14-matrix.spec.js diff --git a/next/docs/evidence/stage-15/acceptance.md b/next/docs/evidence/stage-15/acceptance.md index b82d7a1..858092d 100644 --- a/next/docs/evidence/stage-15/acceptance.md +++ b/next/docs/evidence/stage-15/acceptance.md @@ -8,8 +8,8 @@ ## 减法结果 -- 旧运行时代码:55个文件、61,793行;当前重建运行时代码:219个职责文件、25,053行。 -- 运行时代码净减少36,740行,约59.5%;迁移/备份工具、测试和文档未混入运行时比较。 +- 旧运行时代码:55个文件、61,793行;当前重建运行时代码:219个职责文件、25,077行。 +- 运行时代码净减少36,716行,约59.4%;迁移/备份工具、测试和文档未混入运行时比较。 - 唯一浏览器API、数据网关、LLM网关、弹窗Host、设计令牌和移动规则均通过扫描。 - LLM网关原有3个账户领域具体类型反向导入已改为最小Protocol,未增加第二套服务。 - 超过章程建议行数的算法、Provider和CSS已逐项登记保留原因及拆分触发条件,见`../../final/subtraction-audit.md`。 @@ -28,7 +28,7 @@ - 生产响应统一设置CSP、`nosniff`、拒绝Frame、Permissions Policy、Referrer Policy;HTTPS增加HSTS。 - CSP保留Vue动态宽度样式所需的`style-src 'unsafe-inline'`,脚本仍只允许同源。 - 静态哈希资源长期缓存,SPA入口不缓存;未知API不被SPA接管。 -- 真实迁移库副本市场摘要中位数20.90ms、P95 23.55ms;前端生产JS 298.87KB、CSS 92.40KB(未压缩)。 +- 真实迁移库副本市场摘要中位数20.90ms、P95 23.55ms;前端生产JS 299.80KB、CSS 92.40KB(未压缩)。 ## 最终门禁 @@ -37,7 +37,7 @@ - Vue TypeScript:通过。 - Vitest:3个文件、7项通过。 - Vite生产构建:通过。 -- Playwright:21项通过,单worker,最后一轮耗时约1.6分钟。 +- Playwright:26项通过,单worker,最后一轮耗时约2.6分钟。 - `git diff --check`和已知敏感值扫描:通过。 - Docker:本机未安装,未虚报实构建;列为NAS切换前阻断项。 diff --git a/next/docs/final/completion-audit.md b/next/docs/final/completion-audit.md index 2fbd25a..557f7a3 100644 --- a/next/docs/final/completion-audit.md +++ b/next/docs/final/completion-audit.md @@ -21,15 +21,10 @@ - 数据库迁移、旧库只读迁移、备份、校验恢复、生产静态文件服务和安全响应头已有自动测试。 - PC 统一 Shell、设计令牌、日夜主题和独立移动端布局规则已经建立。 -## 阻断“完整迁移”的验收缺口 +## 阻断“完整迁移”的最终人工验收 -1. 固定案例 `C06`、`C08` 当前为未实现,不能标记通过。 -2. `C05`、`U01` 的主题无白闪需要首帧/加载态直接断言,不能只依赖夜间截图。 -3. `M08` 需要测量 7 板及更高时右侧结论布局,不应只断言文字存在。 -4. `W09` 需要直接检查夜间关键文字对比度和可见性,不能只依赖截图。 -5. `U04` 已增加 4K 内容最大宽度、基础字号和 Shell 高度断言,仍需最终人工视觉验收信息密度。 -6. 第 26 节要求的逐页权限、正常、空、缺失、加载、失败、持久化、日间、夜间、1080P、4K、390px - 覆盖尚未逐格完成;当前 E2E 不能等同于完整矩阵。 +1. `U04` 已增加4K内容最大宽度、基础字号、Shell高度和无横向溢出断言,仍需最终人工视觉验收信息密度。 +2. 第26节逐页状态证据已登记在`page-state-matrix.md`;4K人工视觉单元格在正式切换前保持未关闭。 ## 已在本轮纠正 @@ -58,7 +53,9 @@ 只提示小样本,达到门槛后才显示胜率和平均3日收益,结果按运行持久化且重复读取不漂移。 - 精选策略详情直接展示配置中已有的质量、风险、执行频率、适用环境、失效风险、适用阶段和完整评分权重, 没有补造无法验证的策略元信息。 -- 本轮验证为Ruff、106项pytest、Vue类型检查、7项Vitest、生产构建和21项Playwright全部通过。 +- 图表夜间加载首帧、主题同步切换、7板布局和观心夜间关键文字对比度均已有浏览器直接断言,不再只依赖截图。 +- 16个主工作区、策略跟踪、提醒、复盘助手、账户/会员及系统管理已逐项区分加载、真实空和失败;统一API客户端不会向页面泄露上游错误正文。 +- 本轮验证为Ruff、106项pytest、Vue类型检查、7项Vitest、生产构建和26项Playwright全部通过。 ## 外部环境阻断项 @@ -72,6 +69,6 @@ 1. 本文件登记的用户可见功能缺口全部完成并有直接测试。 2. 85 个固定案例逐项标记为“确定性测试”“浏览器直接断言”或“人工视觉验收”;不得使用宽泛截图代替算法证明。 -3. 第 26 节页面覆盖矩阵逐格完成,人工验收项由用户确认。 +3. `page-state-matrix.md`中的人工验收项由用户确认。 4. Docker 构建、启动、持久化重启、备份恢复和回退在实际容器环境通过。 5. NAS 切换后执行最终冒烟,旧实现只按确认后的清理方案处理。 diff --git a/next/docs/final/page-state-matrix.md b/next/docs/final/page-state-matrix.md new file mode 100644 index 0000000..a1c883f --- /dev/null +++ b/next/docs/final/page-state-matrix.md @@ -0,0 +1,43 @@ +# 页面状态覆盖矩阵 + +更新日期:2026-07-30 + +本矩阵对应《小白复盘-完整产品规格说明书》第26节。`自动`表示确定性单元、Repository或API测试, +`浏览器`表示Playwright直接经过该状态并断言界面行为;`人工待验`只用于自动化无法替代的视觉判断。 +任何“页面可打开”或单张截图都不能单独算作状态证据。 + +证据简称: + +- `B04`:`stage4.spec.js`,账户权限、Shell、账户弹窗、主题即时切换和首次空系统。 +- `B05-B12`:`stage5.spec.js`至`stage12.spec.js`,各领域正常交互、夜间和390px流程。 +- `B13`:`stage13.spec.js`,16工作区移动端可达、320px至4K结构边界和触控目标。 +- `B14`:`stage14-matrix.spec.js`,逐页加载、空、失败和错误正文隐藏。 +- `A账户`:`test_accounts.py`;`A行情`:`test_market_data.py`、`test_market_insights.py`。 +- `A智能`:`test_screener.py`、`test_mentor.py`、`test_heaven.py`;`A私有`:`test_review.py`。 +- `A运维`:`test_operations.py`、`test_model_pool.py`、`test_backup_restore.py`及migration测试。 + +| 页面/能力 | 权限 | 正常数据 | 空/缺失 | 加载/失败 | 持久化 | 日夜 | 1080P/390px | 4K | +|---|---|---|---|---|---|---|---|---| +| 情绪周期 | 自动+A账户 | B06 | B14+A行情 | B14 | A行情 | B04/B06 | B06/B13 | 结构自动;人工待验 | +| 四类股池 | 自动+A账户 | B06 | B14+A行情 | B14 | A行情+A运维 | B06 | B06/B13 | B06;人工待验 | +| 涨停表现/天梯 | 自动+A账户 | B06/B07 | B14 | B14 | A行情 | B06/B07 | B06/B07/B13 | 结构自动;人工待验 | +| 板块轮动 | 自动+A账户 | B07 | B14+A行情 | B14 | A行情 | B07 | B07/B13 | 结构自动;人工待验 | +| 集合竞价 | 自动+A账户 | B08 | B14+A行情 | B14 | A行情 | B08 | B08/B13 | 结构自动;人工待验 | +| 题材库/热榜 | 自动+A账户 | B08 | B14+A行情 | B14 | A行情 | B08 | B08/B13 | 结构自动;人工待验 | +| 龙虎榜/游资档案 | 自动+A账户 | B08 | B14+A行情 | B14 | A行情+A运维 | B08 | B08/B13 | 结构自动;人工待验 | +| 智能选股 | B04/B09+A账户 | B09 | B09/B14+A智能 | B09/B14 | A智能 | B09 | B09/B13 | 结构自动;人工待验 | +| 策略跟踪 | B09+A账户 | B09 | B14+A智能 | B14 | A智能 | B09 | B09/B13 | 结构自动;人工待验 | +| 问师 | B04/B10+A账户 | B10 | B14+A智能 | B10/B14 | A智能 | B10 | B10/B13 | B10;人工待验 | +| 问天 | B04/B11+A账户 | B11 | B14+A智能 | B11/B14 | A智能 | B11 | B11/B13 | 结构自动;人工待验 | +| 我的复盘 | B04/B12+A账户 | B12 | B12/B14+A私有 | B14 | A私有 | B12 | B12/B13 | 结构自动;人工待验 | +| 提醒中心 | 自动+A账户 | B12 | B14+A私有 | B14 | A智能+A私有 | B12 | B12/B13 | 结构自动;人工待验 | +| 复盘助手 | B04/B12+A账户 | B12 | B14 | B12/B14 | A私有+LLM审计 | B12 | B12/B13 | 结构自动;人工待验 | +| 账户/会员 | B04+A账户 | B04 | B14+A账户 | B14 | A账户 | B04 | B04/B13 | 结构自动;人工待验 | +| 系统管理 | B04+A账户 | operations E2E | B14+A运维 | B14 | A运维 | B04 | B04/B13 | B04;人工待验 | + +## 当前结论 + +- 权限、正常、空、缺失、加载、失败、持久化、日间、夜间、1080P和390px均已有直接代码或浏览器证据。 +- 市场工作区、智能工具、私有页面、全局弹窗和系统管理不会再把请求中或请求失败伪装成“暂无数据”。 +- 4K已自动验证Shell尺寸、内容最大宽度、基础字号和无横向溢出;情绪、股池、系统管理和问师保留直接4K截图。 +- 4K“信息密度是否舒适”属于审美判断,仍需正式切换前人工验收,不能由尺寸断言冒充完成。 diff --git a/next/docs/final/spec-coverage.md b/next/docs/final/spec-coverage.md index fe6aeba..6b4ae2f 100644 --- a/next/docs/final/spec-coverage.md +++ b/next/docs/final/spec-coverage.md @@ -1,8 +1,8 @@ -# 产品规格覆盖矩阵(历史阶段记录) +# 产品规格覆盖矩阵 -> 本文件保留阶段 15 当时的证据映射,但不再代表最终完成结论。重新审计已确认 `C06`、`C08` -> 等存在真实功能缺口,部分截图证据也不足以证明对应行为。当前权威完成度见 -> `docs/final/completion-audit.md`;在该审计全部关闭前,不得引用本文件的“85/85”表述。 +> 本文件记录第25节固定案例的当前直接证据。84个案例已由确定性测试或浏览器直接断言证明;`U04` +> 的结构边界已自动验证,但4K信息密度仍需人工视觉验收,因此当前不得表述为“85/85完成”。 +> 第26节逐页状态证据见`page-state-matrix.md`,最终完成度见`completion-audit.md`。 本矩阵以《小白复盘完整产品规格说明书》第25节的85个固定案例为唯一编号源。 “自动”表示确定性单元/API测试;“浏览器”表示Playwright交互、响应式断言和阶段截图共同验收。 @@ -45,10 +45,10 @@ | C02 | `stage5.spec.js`历史页面日期与最新悬浮行情分离 | 浏览器通过 | | C03 | `test_minute_chart_contract_has_real_session_bounds_and_hides_source` | 自动通过 | | C04 | `MarketChart.vue`实体/影线分段及阶段5截图 | 浏览器通过 | -| C05 | 主题令牌加载态及阶段5夜间截图 | 浏览器通过 | -| C06 | `stage5.spec.js`个股详情完整入口 | 浏览器通过 | +| C05 | `stage5.spec.js`在夜间图表请求延迟期间直接检查画布、预览和加载面背景 | 浏览器通过 | +| C06 | `test_entity_detail_uses_selected_bar_previous_close_and_complete_stock_sections`及`stage5.spec.js`完整入口 | 自动+浏览器通过 | | C07 | `stage5.spec.js`非个股详情边界 | 浏览器通过 | -| C08 | 图表前收契约测试及详情摘要浏览器断言 | 自动+浏览器通过 | +| C08 | 同C06直接断言所选交易日、对应前收、涨跌幅及页面摘要 | 自动+浏览器通过 | ## 情绪与市场页面 @@ -61,7 +61,7 @@ | M05 | `stage6.spec.js`明细范围、整页滚动和末行可达 | 浏览器通过 | | M06 | `stage6.spec.js`低行数股池固定状态栏 | 浏览器通过 | | M07 | `stage7.spec.js`天梯展开、收起、滚动与等宽格 | 浏览器通过 | -| M08 | `stage6.spec.js`动态板高与涨停表现布局 | 浏览器通过 | +| M08 | `stage6.spec.js`直接测量7张板高卡、结论顶部/右侧对齐和页面无溢出 | 浏览器通过 | | M09 | `test_sector_members_are_normalized_and_persistently_cached`及`stage7.spec.js` | 自动+浏览器通过 | ## 竞价、题材、热榜与龙虎榜 @@ -116,7 +116,7 @@ | W06 | `heartTiming.test.ts`及`stage11.spec.js` | 自动+浏览器通过 | | W07 | `test_each_heart_cast_appends_exactly_one_line` | 自动通过 | | W08 | `test_hexagram_is_deterministic_and_contains_only_six_lines`及阶段11页面断言 | 自动+浏览器通过 | -| W09 | 阶段11夜间观心截图和可读性断言 | 浏览器通过 | +| W09 | `stage11.spec.js`直接计算提示、历史、静音和重新观心文字对比度均不低于4.5 | 浏览器通过 | ## 复盘、主题与响应式 @@ -126,15 +126,15 @@ | R02 | `test_trade_summary_uses_only_realized_fields_and_keeps_zero_in_denominator` | 自动通过 | | R03 | `test_private_review_records_are_strictly_scoped_and_daily_notes_upsert` | 自动通过 | | R04 | `stage12.spec.js`历史展开、收起与滚动 | 浏览器通过 | -| U01 | 阶段4至13日夜切换E2E,无白闪/控制台错误 | 浏览器通过 | +| U01 | `stage4.spec.js`直接检查主题切换同步表面及夜间重载首帧;`stage5.spec.js`覆盖图表加载态 | 浏览器通过 | | U02 | 阶段6的1920×1080整页滚动与明细可达 | 浏览器通过 | | U03 | 阶段11观气/观心滚动与背景断言 | 浏览器通过 | -| U04 | 阶段4至13的3840×2160截图及密度验收 | 浏览器通过 | +| U04 | `stage13.spec.js`自动验证4K最大宽度、字号、Shell和无溢出;信息密度 | 人工待验 | | U05 | `stage13.spec.js`390×844、五入口与44px触控目标 | 浏览器通过 | | U06 | `stage13.spec.js`320px 16工作区、纵向记录/局部横滚 | 浏览器通过 | ## 汇总 -- 固定案例:85/85已有可追踪证据。 -- 确定性、权限、隔离和失败语义均由自动测试覆盖;视觉案例同时保留真实浏览器截图。 +- 固定案例:84/85已完成直接验收,`U04`保留最终人工视觉验收。 +- 确定性、权限、隔离和失败语义均由自动测试覆盖;视觉案例保留真实浏览器断言和阶段截图。 - Docker镜像构建、NAS容器重启与正式端口切换不属于85个页面案例,但仍是切换前阻断项,见`cutover-checklist.md`。 diff --git a/next/frontend/src/app/system/MembershipAdminPanel.vue b/next/frontend/src/app/system/MembershipAdminPanel.vue index 787bfb8..f1e8923 100644 --- a/next/frontend/src/app/system/MembershipAdminPanel.vue +++ b/next/frontend/src/app/system/MembershipAdminPanel.vue @@ -15,6 +15,7 @@ type AccountMembership = { user_id: number; username: string; is_admin: boolean; const ui = useUiStore(); const accounts = ref([]); +const loading = ref(true); const selectedId = ref(null); const duration = ref("1_month"); const dailyLimit = ref(50); @@ -30,11 +31,15 @@ const sortedAccounts = computed(() => ); async function load(): Promise { + loading.value = true; + errorMessage.value = ""; try { accounts.value = await api.get("/admin/memberships"); if (selectedId.value === null && accounts.value[0]) select(accounts.value[0]); } catch (error) { errorMessage.value = error instanceof Error ? error.message : "会员列表读取失败。"; + } finally { + loading.value = false; } } @@ -76,7 +81,9 @@ onMounted(load);

账号会员状态

{{ accounts.length }} 个账号
-
+

正在读取会员列表

+ +
diff --git a/next/frontend/src/app/system/ModelPoolPanel.vue b/next/frontend/src/app/system/ModelPoolPanel.vue index 54903f1..575290b 100644 --- a/next/frontend/src/app/system/ModelPoolPanel.vue +++ b/next/frontend/src/app/system/ModelPoolPanel.vue @@ -17,6 +17,7 @@ type ModelItem = { const ui = useUiStore(); const models = ref([]); +const loading = ref(true); const editingId = ref(null); const primaryId = ref(null); const fallbackId = ref(null); @@ -45,12 +46,16 @@ function edit(model: ModelItem): void { } async function load(): Promise { + loading.value = true; + errorMessage.value = ""; try { models.value = await api.get("/admin/models"); primaryId.value = models.value.find((item) => item.is_primary)?.id ?? null; fallbackId.value = models.value.find((item) => item.is_fallback)?.id ?? null; } catch (error) { errorMessage.value = error instanceof Error ? error.message : "模型池读取失败。"; + } finally { + loading.value = false; } } @@ -139,11 +144,15 @@ onMounted(load);

模型池

{{ models.length }} / 20
- -

尚未添加模型

+

正在读取模型池

+ +
diff --git a/next/frontend/src/pages/review/AlertsPanel.vue b/next/frontend/src/pages/review/AlertsPanel.vue index cec752e..c2fb2ee 100644 --- a/next/frontend/src/pages/review/AlertsPanel.vue +++ b/next/frontend/src/pages/review/AlertsPanel.vue @@ -10,14 +10,16 @@ const ui = useUiStore(); const items = ref([]); const unreadOnly = ref(false); const loading = ref(true); +const error = ref(""); const form = reactive({ title: "", remind_date: new Date().toISOString().slice(0, 10), code: "", content: "" }); async function load(): Promise { loading.value = true; + error.value = ""; try { const result = await reviewApi.alerts(unreadOnly.value); items.value = result.items; ui.alertUnread = result.unread_count; - } catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "提醒读取失败"); } + } catch (reason) { error.value = reason instanceof Error ? reason.message : "提醒读取失败"; } finally { loading.value = false; } } async function create(): Promise { @@ -39,6 +41,7 @@ onMounted(load);
正在读取提醒
+
{{ item.title }}{{ item.due ? (item.is_read ? "已读" : "未读") : "未到期" }}

{{ item.content }}

{{ item.available_date }}
diff --git a/next/frontend/src/pages/review/AssistantPanel.vue b/next/frontend/src/pages/review/AssistantPanel.vue index 1f4862b..235a9b6 100644 --- a/next/frontend/src/pages/review/AssistantPanel.vue +++ b/next/frontend/src/pages/review/AssistantPanel.vue @@ -12,6 +12,8 @@ const ui = useUiStore(); const messages = ref([]); const question = ref(""); const loading = ref(false); +const historyLoading = ref(false); +const historyError = ref(""); const messageRoot = ref(null); let controller: AbortController | undefined; const locked = computed(() => !session.account?.smart_access); @@ -23,8 +25,11 @@ const prompts: Array<[string, string]> = [ ]; async function load(): Promise { if (locked.value) return; + historyLoading.value = true; + historyError.value = ""; try { messages.value = await reviewApi.assistantMessages(); } - catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "对话读取失败"); } + catch (reason) { historyError.value = reason instanceof Error ? reason.message : "对话读取失败"; } + finally { historyLoading.value = false; } } async function send(): Promise { const value = question.value.trim(); @@ -58,7 +63,7 @@ onMounted(load);
复盘助手仅对会员开放开通会员后可读取市场统计、策略跟踪与个人复盘记录,进行统一复盘分析。
-
{{ message.role === 'user' ? '我' : '复盘助手' }}

{{ message.content || (loading ? "正在整理复盘材料" : "") }}

可以从市场、策略或自己的交易记录开始复盘
+
正在读取复盘助手对话
复盘助手对话暂不可用

{{ historyError }}

diff --git a/next/tests/e2e/stage11.spec.js b/next/tests/e2e/stage11.spec.js index 9c7168c..92e5c13 100644 --- a/next/tests/e2e/stage11.spec.js +++ b/next/tests/e2e/stage11.spec.js @@ -6,6 +6,24 @@ const { expect, test } = require("@playwright/test"); const evidence = path.resolve(__dirname, "../../docs/evidence/stage-11"); test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true })); +async function contrastRatio(locator) { + return locator.evaluate((element) => { + const rgba = (value) => (value.match(/[\d.]+/g) ?? []).map(Number); + const luminance = (value) => { + const [r, g, b] = rgba(value).slice(0, 3).map((channel) => { + const normalized = channel / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + let background = element; + while (background && rgba(getComputedStyle(background).backgroundColor)[3] === 0) background = background.parentElement; + const foregroundLuminance = luminance(getComputedStyle(element).color); + const backgroundLuminance = luminance(getComputedStyle(background ?? document.documentElement).backgroundColor); + return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) / (Math.min(foregroundLuminance, backgroundLuminance) + 0.05); + }); +} + async function authenticate(page, username, password) { await page.goto("/"); await page.getByLabel("账号名").fill(username); @@ -143,7 +161,17 @@ test("stage 11 Heaven workflows, animations and responsive layouts", async ({ pa await page.clock.install(); await page.locator(".heaven-mode-tabs button").filter({ hasText: "观心" }).click(); + const heartText = page.getByRole("heading", { name: "把所问之事留在心里" }); + const historyButton = page.getByRole("button", { name: "历史记录", exact: true }).first(); + const muteButton = page.getByRole("button", { name: "静音", exact: true }); + await expect(heartText).toBeVisible(); + expect(await contrastRatio(heartText)).toBeGreaterThanOrEqual(4.5); + expect(await contrastRatio(historyButton)).toBeGreaterThanOrEqual(4.5); + expect(await contrastRatio(muteButton)).toBeGreaterThanOrEqual(4.5); await page.getByRole("button", { name: "开始静心", exact: true }).click(); + const resetButton = page.getByRole("button", { name: "重新观心", exact: true }); + await expect(resetButton).toBeVisible(); + expect(await contrastRatio(resetButton)).toBeGreaterThanOrEqual(4.5); await page.clock.fastForward(1200); await expect(page.getByText("吸", { exact: true })).toBeVisible(); await page.clock.fastForward(45_000); diff --git a/next/tests/e2e/stage14-matrix.spec.js b/next/tests/e2e/stage14-matrix.spec.js new file mode 100644 index 0000000..51280de --- /dev/null +++ b/next/tests/e2e/stage14-matrix.spec.js @@ -0,0 +1,214 @@ +const { expect, test } = require("@playwright/test"); + +const marketKeys = [ + "emotion", "pool", "broken", "limit-down", "yesterday", "performance", + "ladder", "rotation", "auction", "themes", "popularity", "dragon-list", +]; + +const summary = { + context: { + requested_date: "2026-07-30", actual_date: "2026-07-30", previous_date: "2026-07-29", + observed_at: "2026-07-30T15:00:00+08:00", state: "final", carried_forward: false, message: "", + }, + values: { temperature: 42, limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 }, +}; + +async function authenticate(page) { + await page.goto("/"); + await page.getByLabel("账号名").fill("stage4admin"); + await page.getByLabel("密码").fill("Stage4-pass-123!"); + await page.getByRole("button", { name: "登录", exact: true }).click(); + await expect(page.locator(".sidebar")).toBeVisible(); +} + +async function delay(milliseconds = 180) { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function baseRoutes(page) { + await page.route("**/api/market/summary", (route) => route.fulfill({ + contentType: "application/json", body: JSON.stringify(summary), + })); + await page.route("**/api/review/alerts?*", (route) => route.fulfill({ + contentType: "application/json", body: JSON.stringify({ items: [], unread_count: 0, as_of: "2026-07-30" }), + })); +} + +test("all market workspaces expose loading, empty and failure truthfully", async ({ page }) => { + let state = "empty"; + await baseRoutes(page); + const respond = async (route) => { + if (state === "loading-empty") await delay(); + if (state === "failure") return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "矩阵测试上游失败" }) }); + return route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "", message: "等待管理员首次同步真实收盘行情" }) }); + }; + await page.route("**/api/market/workspaces/*", respond); + await page.route("**/api/market/insights/*", respond); + await authenticate(page); + + for (const key of marketKeys) { + state = "loading-empty"; + await page.goto(`/workspace/${key}`); + await expect(page.getByText("正在读取本地复盘数据", { exact: true }), `loading ${key}`).toBeVisible(); + await expect(page.getByRole("heading", { name: "暂无真实行情快照" }), `empty ${key}`).toBeVisible(); + + state = "failure"; + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { name: "页面数据暂不可用" }), `failure ${key}`).toBeVisible(); + await expect(page.getByText("请求失败,请稍后重试。", { exact: true }), `failure reason ${key}`).toBeVisible(); + } +}); + +test("smart and private workspaces expose loading, empty and failure truthfully", async ({ page }) => { + let state = "empty"; + await baseRoutes(page); + + const response = async (route, payload) => { + if (state === "loading-empty") await delay(); + if (state === "failure") return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "矩阵测试服务失败" }) }); + return route.fulfill({ contentType: "application/json", body: JSON.stringify(payload) }); + }; + const catalog = { factor_groups: {}, factors: {}, stage: [], curated: [] }; + const screener = { trade_date: "2026-07-30", message: "", catalog, stage_runs: [], curated_runs: [], custom_strategies: [], custom_runs: [] }; + const review = { trade_date: "2026-07-30", watchlist: [], daily: null, history: [], trades: [], trade_summary: { total: 0, realized: 0, win_rate: null, pnl_amount: null, average_position: null } }; + await page.route("**/api/screener/catalog", (route) => response(route, catalog)); + await page.route("**/api/screener?*", (route) => response(route, screener)); + await page.route("**/api/screener/tracks", (route) => response(route, [])); + await page.route("**/api/mentors/setup?*", (route) => response(route, { trade_date: "2026-07-30", mentors: [] })); + await page.route("**/api/heaven/setup?*", (route) => response(route, { date: "2026-07-30", fortune: {}, daily_fortune: null, history: [] })); + await page.route("**/api/review?*", (route) => response(route, review)); + await authenticate(page); + + const cases = [ + { path: "/workspace/screener", key: "screener", loading: "正在读取本地选股归档", empty: "等待盘后判定", failure: "智能选股暂不可用" }, + { path: "/workspace/screener/tracking", key: "tracking", loading: "正在读取跟踪记录", empty: "暂无跟踪记录", failure: "跟踪记录暂不可用" }, + { path: "/workspace/mentor", key: "mentor", loading: "正在读取思维模型", empty: "从问题出发,按模型的方法拆解市场。", failure: "问师暂不可用" }, + { path: "/workspace/heaven", key: "heaven", loading: "正在推演当日基础气机", empty: "请输入股票代码或股票名称", failure: "请求失败,请稍后重试。" }, + { path: "/workspace/review", key: "review", loading: "正在读取个人复盘记录", empty: "暂无自选", failure: "复盘记录暂不可用" }, + ]; + + for (const item of cases) { + state = "loading-empty"; + await page.goto(item.path); + await expect(page.getByText(item.loading, { exact: true }), `loading ${item.key}`).toBeVisible(); + await expect(page.getByText(item.empty, { exact: true }).first(), `empty ${item.key}`).toBeVisible(); + + state = "failure"; + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByText(item.failure, { exact: true }).first(), `failure ${item.key}`).toBeVisible(); + await expect(page.getByText("请求失败,请稍后重试。", { exact: true }).first(), `failure reason ${item.key}`).toBeVisible(); + } +}); + +test("alerts and review assistant distinguish loading, empty and failure", async ({ page }) => { + let state = "empty"; + await baseRoutes(page); + const response = async (route, payload) => { + if (state === "loading-empty") await delay(); + if (state === "failure") return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "不应暴露的上游正文" }) }); + return route.fulfill({ contentType: "application/json", body: JSON.stringify(payload) }); + }; + await page.route("**/api/review/alerts?*", (route) => response(route, { items: [], unread_count: 0, as_of: "2026-07-30" })); + await page.route("**/api/review/assistant/messages", (route) => response(route, [])); + await authenticate(page); + + state = "loading-empty"; + await page.getByRole("button", { name: "提醒中心" }).click(); + let dialog = page.getByRole("dialog", { name: "提醒中心" }); + await expect(dialog.getByText("正在读取提醒", { exact: true })).toBeVisible(); + await expect(dialog.getByRole("heading", { name: "暂无提醒" })).toBeVisible(); + await dialog.getByLabel("关闭").click(); + state = "failure"; + await page.getByRole("button", { name: "提醒中心" }).click(); + dialog = page.getByRole("dialog", { name: "提醒中心" }); + await expect(dialog.getByRole("heading", { name: "提醒暂不可用" })).toBeVisible(); + await expect(dialog.getByText("请求失败,请稍后重试。", { exact: true })).toBeVisible(); + await dialog.getByLabel("关闭").click(); + + state = "loading-empty"; + await page.getByRole("button", { name: "复盘助手" }).click(); + dialog = page.getByRole("dialog", { name: "复盘助手" }); + await expect(dialog.getByText("正在读取复盘助手对话", { exact: true })).toBeVisible(); + await expect(dialog.getByText("可以从市场、策略或自己的交易记录开始复盘", { exact: true })).toBeVisible(); + await dialog.getByLabel("关闭").click(); + state = "failure"; + await page.getByRole("button", { name: "复盘助手" }).click(); + dialog = page.getByRole("dialog", { name: "复盘助手" }); + await expect(dialog.getByText("复盘助手对话暂不可用", { exact: true })).toBeVisible(); + await expect(dialog.getByText("请求失败,请稍后重试。", { exact: true })).toBeVisible(); +}); + +test("system model and membership panels distinguish loading, empty and failure", async ({ page }) => { + let state = "empty"; + await baseRoutes(page); + const response = async (route, payload) => { + if (state === "loading-empty") await delay(); + if (state === "failure") return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "不应暴露的系统错误" }) }); + return route.fulfill({ contentType: "application/json", body: JSON.stringify(payload) }); + }; + await page.route("**/api/admin/system/credentials", (route) => route.fulfill({ contentType: "application/json", body: "[]" })); + await page.route("**/api/admin/operations/jobs?*", (route) => route.fulfill({ contentType: "application/json", body: "[]" })); + await page.route("**/api/admin/models", (route) => response(route, [])); + await page.route("**/api/admin/memberships", (route) => response(route, [])); + await authenticate(page); + await page.goto("/system"); + + state = "loading-empty"; + await page.getByRole("button", { name: "模型池", exact: true }).click(); + await expect(page.getByText("正在读取模型池", { exact: true })).toBeVisible(); + await expect(page.getByText("尚未添加模型", { exact: true })).toBeVisible(); + state = "failure"; + await page.getByRole("button", { name: "行情管理", exact: true }).click(); + await page.getByRole("button", { name: "模型池", exact: true }).click(); + await expect(page.getByText("请求失败,请稍后重试。", { exact: true }).first()).toBeVisible(); + + state = "loading-empty"; + await page.getByRole("button", { name: "会员管理", exact: true }).click(); + await expect(page.getByText("正在读取会员列表", { exact: true })).toBeVisible(); + await expect(page.getByText("请选择账号", { exact: true })).toBeVisible(); + state = "failure"; + await page.getByRole("button", { name: "行情管理", exact: true }).click(); + await page.getByRole("button", { name: "会员管理", exact: true }).click(); + await expect(page.getByText("请求失败,请稍后重试。", { exact: true }).first()).toBeVisible(); +}); + +test("account profile and membership dialogs distinguish loading, inactive and failure", async ({ page }) => { + let state = "empty"; + await baseRoutes(page); + const response = async (route, payload) => { + if (state === "loading-empty") await delay(); + if (state === "failure") return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "不应暴露的账户错误" }) }); + return route.fulfill({ contentType: "application/json", body: JSON.stringify(payload) }); + }; + await page.route("**/api/account/profile", (route) => response(route, { configured: false, birth_date: null, birth_time: null, gender: null, privacy_notice: "原始出生资料加密保存,仅当前账号可见。" })); + await page.route("**/api/account/membership", (route) => response(route, { status: "none", active: false, is_permanent: false, expires_at: null, remaining_days: null, daily_limit: 50, used_today: 0, remaining_today: 50, quota_exempt: false, smart_access: true, description: "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。", usage_package_available: false })); + await authenticate(page); + + const openAccountItem = async (name) => { + await page.getByRole("button", { name: /stage4admin/ }).click(); + await page.locator(".account-menu").getByRole("button", { name, exact: true }).click(); + }; + + state = "loading-empty"; + await openAccountItem("个人资料"); + let dialog = page.getByRole("dialog", { name: "个人资料" }); + await expect(dialog.getByText("正在读取个人资料", { exact: true })).toBeVisible(); + await expect(dialog.getByLabel("出生日期")).toHaveValue(""); + await dialog.getByLabel("关闭").click(); + state = "failure"; + await openAccountItem("个人资料"); + dialog = page.getByRole("dialog", { name: "个人资料" }); + await expect(dialog.getByRole("alert")).toHaveText("请求失败,请稍后重试。"); + await dialog.getByLabel("关闭").click(); + + state = "loading-empty"; + await openAccountItem("会员状态"); + dialog = page.getByRole("dialog", { name: "会员状态" }); + await expect(dialog.getByText("正在读取会员状态", { exact: true })).toBeVisible(); + await expect(dialog.getByText("未开通", { exact: true }).first()).toBeVisible(); + await dialog.getByLabel("关闭").click(); + state = "failure"; + await openAccountItem("会员状态"); + dialog = page.getByRole("dialog", { name: "会员状态" }); + await expect(dialog.getByRole("alert")).toHaveText("请求失败,请稍后重试。"); +}); diff --git a/next/tests/e2e/stage4.spec.js b/next/tests/e2e/stage4.spec.js index 8572fd1..c5464e5 100644 --- a/next/tests/e2e/stage4.spec.js +++ b/next/tests/e2e/stage4.spec.js @@ -85,8 +85,21 @@ test("authentication, shell, dialogs, theme and responsive structure", async ({ await page.getByRole("button", { name: "夜间" }).click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + const switchedSurfaces = await page.evaluate(() => { + const selectors = ["html", "body", ".sidebar", ".topbar", ".market-strip", ".card"]; + return selectors.map((selector) => { + const element = document.querySelector(selector); + return { selector, background: element ? getComputedStyle(element).backgroundColor : null }; + }); + }); + expect(switchedSurfaces.every(({ background }) => background && background !== "rgb(255, 255, 255)")).toBe(true); await page.reload(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + expect(await page.evaluate(() => ({ + theme: document.documentElement.dataset.theme, + canvas: getComputedStyle(document.documentElement).backgroundColor, + body: getComputedStyle(document.body).backgroundColor, + }))).toEqual({ theme: "dark", canvas: "rgb(18, 20, 22)", body: "rgb(18, 20, 22)" }); await expect(page.getByRole("button", { name: "日间" })).toBeVisible(); await page.screenshot({ path: path.join(evidence, "shell-dark-1920x1080.jpg"), type: "jpeg", quality: 82 }); diff --git a/next/tests/e2e/stage5.spec.js b/next/tests/e2e/stage5.spec.js index 936798c..2429ebb 100644 --- a/next/tests/e2e/stage5.spec.js +++ b/next/tests/e2e/stage5.spec.js @@ -60,6 +60,7 @@ function chartPayload(interval) { test("latest snapshot, grouped search, chart preview and entity detail", async ({ page }) => { const consoleErrors = []; + let delayStockChart = false; page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) { consoleErrors.push(message.text()); @@ -86,7 +87,8 @@ test("latest snapshot, grouped search, chart preview and entity detail", async ( const interval = route.request().url().endsWith("/minute") ? "minute" : "day"; return route.fulfill({ contentType: "application/json", body: JSON.stringify(chartPayload(interval)) }); }); - await page.route("**/api/market/entities/stock/000001.SZ/charts/*", (route) => { + await page.route("**/api/market/entities/stock/000001.SZ/charts/*", async (route) => { + if (delayStockChart) await new Promise((resolve) => setTimeout(resolve, 250)); const interval = route.request().url().endsWith("/minute") ? "minute" : "day"; return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...chartPayload(interval), entity_type: "stock", identifier: "000001.SZ", code: "000001", name: "平安银行" }) }); }); @@ -177,6 +179,22 @@ test("latest snapshot, grouped search, chart preview and entity detail", async ( await page.getByRole("button", { name: "加入自选" }).click(); await expect(page.getByRole("button", { name: "移出自选" })).toBeVisible(); + delayStockChart = true; + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator(".preview-state")).toHaveText("正在读取真实行情"); + const loadingSurfaces = await page.evaluate(() => ({ + theme: document.documentElement.dataset.theme, + canvas: getComputedStyle(document.documentElement).backgroundColor, + preview: getComputedStyle(document.querySelector(".market-preview")).backgroundColor, + loading: getComputedStyle(document.querySelector(".preview-state")).backgroundColor, + })); + expect(loadingSurfaces.theme).toBe("dark"); + expect(loadingSurfaces.canvas).not.toBe("rgb(255, 255, 255)"); + expect(loadingSurfaces.preview).not.toBe("rgb(255, 255, 255)"); + expect(loadingSurfaces.loading).not.toBe("rgb(255, 255, 255)"); + delayStockChart = false; + await expect(page.locator(".market-chart")).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); await expect(page.locator(".market-chart")).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0); diff --git a/next/tests/e2e/stage6.spec.js b/next/tests/e2e/stage6.spec.js index 86f87e0..76c12ed 100644 --- a/next/tests/e2e/stage6.spec.js +++ b/next/tests/e2e/stage6.spec.js @@ -126,6 +126,18 @@ test("emotion and pool workspaces remain usable across desktop and mobile", asyn await page.getByRole("link", { name: /涨停表现/ }).click(); await expect(page.getByText("7板")).toBeVisible(); await expect(page.getByRole("heading", { name: "今日结论" })).toBeVisible(); + const performanceGeometry = await page.evaluate(() => { + const layout = document.querySelector(".performance-layout").getBoundingClientRect(); + const levels = document.querySelector(".performance-levels").getBoundingClientRect(); + const conclusion = document.querySelector(".performance-conclusion").getBoundingClientRect(); + return { + cards: document.querySelectorAll(".performance-level-card").length, + topGap: Math.abs(levels.top - conclusion.top), + conclusionRightGap: Math.abs(layout.right - conclusion.right), + overflow: document.documentElement.scrollWidth - window.innerWidth, + }; + }); + expect(performanceGeometry).toEqual({ cards: 7, topGap: 0, conclusionRightGap: 0, overflow: 0 }); await page.setViewportSize({ width: 390, height: 844 }); expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0); await page.screenshot({ path: path.join(evidence, "performance-dark-390x844.jpg"), type: "jpeg", quality: 82 });
账号身份会员状态到期时间