rebuild(audit): verify page state matrix

This commit is contained in:
leefer
2026-07-30 11:12:37 +08:00
parent b79b4ba280
commit 4eafa1cc3f
13 changed files with 386 additions and 37 deletions
+4 -4
View File
@@ -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 PolicyHTTPS增加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:通过。
- Vitest3个文件、7项通过。
- Vite生产构建:通过。
- Playwright21项通过,单worker,最后一轮耗时约1.6分钟。
- Playwright26项通过,单worker,最后一轮耗时约2.6分钟。
- `git diff --check`和已知敏感值扫描:通过。
- Docker:本机未安装,未虚报实构建;列为NAS切换前阻断项。
+7 -10
View File
@@ -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 切换后执行最终冒烟,旧实现只按确认后的清理方案处理。
+43
View File
@@ -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“信息密度是否舒适”属于审美判断,仍需正式切换前人工验收,不能由尺寸断言冒充完成。
+13 -13
View File
@@ -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`
@@ -15,6 +15,7 @@ type AccountMembership = { user_id: number; username: string; is_admin: boolean;
const ui = useUiStore();
const accounts = ref<AccountMembership[]>([]);
const loading = ref(true);
const selectedId = ref<number | null>(null);
const duration = ref("1_month");
const dailyLimit = ref(50);
@@ -30,11 +31,15 @@ const sortedAccounts = computed(() =>
);
async function load(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
accounts.value = await api.get<AccountMembership[]>("/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);
<div class="system-grid member-grid">
<section class="card">
<header class="card-header"><h2>账号会员状态</h2><span class="tag">{{ accounts.length }} 个账号</span></header>
<div class="table-wrap">
<p v-if="loading" class="panel-loading muted">正在读取会员列表</p>
<p v-else-if="errorMessage" class="panel-loading field-error" role="alert">{{ errorMessage }}</p>
<div v-else class="table-wrap">
<table class="data-table">
<thead><tr><th>账号</th><th>身份</th><th>会员状态</th><th>到期时间</th><th class="num" :aria-sort="limitDescending ? 'descending' : 'ascending'"><button class="sort-button" type="button" @click="limitDescending = !limitDescending">每日上限 <span aria-hidden="true">{{ limitDescending ? "↓" : "↑" }}</span></button></th></tr></thead>
<tbody>
@@ -17,6 +17,7 @@ type ModelItem = {
const ui = useUiStore();
const models = ref<ModelItem[]>([]);
const loading = ref(true);
const editingId = ref<number | null>(null);
const primaryId = ref<number | null>(null);
const fallbackId = ref<number | null>(null);
@@ -45,12 +46,16 @@ function edit(model: ModelItem): void {
}
async function load(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
models.value = await api.get<ModelItem[]>("/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);
<section class="card">
<header class="card-header"><h2>模型池</h2><span class="tag">{{ models.length }} / 20</span></header>
<div class="model-list">
<button v-for="model in models" :key="model.id" class="model-row" :class="{ active: editingId === model.id }" type="button" @click="edit(model)">
<span><strong>{{ model.display_name }}</strong><small>{{ model.model_identifier }}</small></span>
<span class="model-tags"><span v-if="model.is_primary" class="tag">主模型</span><span v-if="model.is_fallback" class="tag">辅助</span></span>
</button>
<p v-if="!models.length" class="panel-loading muted">尚未添加模型</p>
<p v-if="loading" class="panel-loading muted">正在读取模型池</p>
<p v-else-if="errorMessage" class="panel-loading field-error" role="alert">{{ errorMessage }}</p>
<template v-else>
<button v-for="model in models" :key="model.id" class="model-row" :class="{ active: editingId === model.id }" type="button" @click="edit(model)">
<span><strong>{{ model.display_name }}</strong><small>{{ model.model_identifier }}</small></span>
<span class="model-tags"><span v-if="model.is_primary" class="tag">主模型</span><span v-if="model.is_fallback" class="tag">辅助</span></span>
</button>
<p v-if="!models.length" class="panel-loading muted">尚未添加模型</p>
</template>
</div>
</section>
<div class="system-stack">
@@ -10,14 +10,16 @@ const ui = useUiStore();
const items = ref<AlertItem[]>([]);
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<void> {
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<void> {
@@ -39,6 +41,7 @@ onMounted(load);
<div class="alerts-toolbar"><div class="segmented"><button type="button" :class="{ active: !unreadOnly }" @click="select(false)">全部</button><button type="button" :class="{ active: unreadOnly }" @click="select(true)">未读</button></div><button class="btn btn-small" type="button" @click="markAll">全部已读</button></div>
<form class="alert-create" @submit.prevent="create"><div class="alert-create-grid"><label class="field"><span class="field-label">标题</span><input v-model="form.title" class="input" maxlength="80" required /></label><label class="field"><span class="field-label">提醒日期</span><input v-model="form.remind_date" class="input" type="date" required /></label><label class="field"><span class="field-label">股票代码(可选)</span><input v-model="form.code" class="input" maxlength="12" /></label></div><label class="field"><span class="field-label">提醒内容(可选)</span><textarea v-model="form.content" class="textarea" maxlength="500"></textarea></label><div class="form-actions"><button class="btn btn-primary" type="submit">保存提醒</button></div></form>
<div v-if="loading" class="workspace-state">正在读取提醒</div>
<EmptyState v-else-if="error" title="提醒暂不可用" :description="error" />
<div v-else-if="items.length" class="alert-list"><article v-for="item in items" :key="item.id" :class="{ unread: !item.is_read && item.due }"><div><strong>{{ item.title }}</strong><span class="tag">{{ item.due ? (item.is_read ? "已读" : "未读") : "未到期" }}</span></div><p>{{ item.content }}</p><small>{{ item.available_date }}<template v-if="item.code"> · <MarketEntityLink :entity="{ entity_type: 'stock', identifier: item.code, code: item.code, name: item.code, sector: null }" /></template></small><div class="table-actions"><button v-if="item.due && !item.is_read" class="btn btn-small" type="button" @click="mark(item)">标为已读</button><button class="btn btn-small" type="button" @click="remove(item)">删除</button></div></article></div>
<EmptyState v-else title="暂无提醒" description="可以创建站内提醒,策略跟踪反馈也会自动汇总到这里。" />
</div>
@@ -12,6 +12,8 @@ const ui = useUiStore();
const messages = ref<AssistantMessage[]>([]);
const question = ref("");
const loading = ref(false);
const historyLoading = ref(false);
const historyError = ref("");
const messageRoot = ref<HTMLElement | null>(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<void> {
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<void> {
const value = question.value.trim();
@@ -58,7 +63,7 @@ onMounted(load);
<div class="assistant-panel">
<div v-if="locked" class="notice notice-warning"><strong>复盘助手仅对会员开放</strong><span>开通会员后可读取市场统计策略跟踪与个人复盘记录进行统一复盘分析</span></div>
<div class="assistant-content" :class="{ 'locked-content': locked }" :aria-disabled="locked">
<div ref="messageRoot" class="assistant-messages"><article v-for="message in messages" :key="message.id" :class="message.role"><span>{{ message.role === 'user' ? '我' : '复盘助手' }}</span><p>{{ message.content || (loading ? "正在整理复盘材料" : "") }}</p></article><div v-if="!messages.length" class="assistant-empty">可以从市场策略或自己的交易记录开始复盘</div></div>
<div ref="messageRoot" class="assistant-messages"><div v-if="historyLoading" class="assistant-empty">正在读取复盘助手对话</div><div v-else-if="historyError" class="assistant-empty"><strong>复盘助手对话暂不可用</strong><p>{{ historyError }}</p></div><template v-else><article v-for="message in messages" :key="message.id" :class="message.role"><span>{{ message.role === 'user' ? '我' : '复盘助手' }}</span><p>{{ message.content || (loading ? "正在整理复盘材料" : "") }}</p></article><div v-if="!messages.length" class="assistant-empty">可以从市场策略或自己的交易记录开始复盘</div></template></div>
<div class="assistant-prompts"><button v-for="item in prompts" :key="item[0]" type="button" :disabled="locked" @click="usePrompt(item[1])">{{ item[0] }}</button></div>
<form class="assistant-compose" @submit.prevent="send"><textarea v-model="question" class="textarea" maxlength="2000" placeholder="询问市场位置、主线、策略表现或自己的交易模式" :disabled="locked" required></textarea><div><button v-if="loading" class="btn" type="button" @click="stop">停止</button><button v-else class="btn btn-primary" type="submit" :disabled="locked">发送</button><button class="btn btn-ghost" type="button" :disabled="locked" @click="clear">清空对话</button></div></form>
</div>
+28
View File
@@ -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);
+214
View File
@@ -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("请求失败,请稍后重试。");
});
+13
View File
@@ -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 });
+19 -1
View File
@@ -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);
+12
View File
@@ -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 });