Files
xiaobaifupan/app/tests/e2e/app-shell.spec.js
T

3738 lines
201 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { test, expect } = require("@playwright/test");
const dashboard = {
meta: {
trade_date: "2026-07-22",
requested_date: "2026-07-22",
source: "tushare",
realtime: false,
cached: true,
market_status: "closed",
updated_at: "2026-07-22T15:00:00+08:00",
},
overview: {
up_count: 2100,
down_count: 2800,
limit_up_count: 42,
limit_down_count: 8,
broken_count: 17,
seal_rate: 71.2,
amount_billion: 12600,
sentiment_score: 48,
},
limits: [],
broken: [],
down_limits: [],
yesterday_limits: [],
limit_performance: [],
ladders: [],
sectors: [],
sector_rotation: [],
};
function session(role = "admin", subscribed = true) {
return {
authenticated: true,
csrf_token: "test-csrf",
user: {
id: role === "admin" ? 1 : 2,
username: role === "admin" ? "admin_user" : "normal_user",
role,
membership: {
active: role === "admin" || subscribed,
subscribed,
is_admin: role === "admin",
},
},
};
}
function waitForApplicationRuntime(page) {
return expect(page.locator("body")).toHaveAttribute("data-runtime-ready", "true");
}
function installApplicationNavigation(page) {
if (page.__xiaobaiNavigationWrapped) return;
page.__xiaobaiNavigationWrapped = true;
const goto = page.goto.bind(page);
const reload = page.reload.bind(page);
page.goto = async (...args) => {
const response = await goto(...args);
await waitForApplicationRuntime(page);
return response;
};
page.reload = async (...args) => {
const response = await reload(...args);
await waitForApplicationRuntime(page);
return response;
};
}
function mentorDirectory(role = "admin") {
const mentors = [
{
id: "source-a",
name: "原帖老师",
description: "依据长期实盘原帖提炼",
tagline: "先看周期,再看机会。",
focus: ["情绪周期", "仓位纪律"],
evidence: { grade: "A", label: "实盘原帖", note: "长期原始实盘记录" },
quality: { score: 6, total: 6, status: "pass" },
private: false,
},
{
id: "source-b",
name: "多源老师",
description: "依据公开访谈与多源资料整理",
tagline: "确认之后再行动。",
focus: ["主线确认", "风险管理"],
evidence: { grade: "B", label: "多源整理", note: "公开访谈与多源材料" },
quality: { score: 6, total: 6, status: "pass" },
private: false,
},
{
id: "source-c",
name: "推演老师",
description: "公开语录较少,以行为推演为主",
tagline: "只讨论可验证的行为。",
focus: ["行为推演", "诚实边界"],
evidence: { grade: "C", label: "行为推演", note: "公开语录较少" },
quality: { score: 5, total: 6, status: "conditional" },
private: false,
},
];
for (let index = 1; index <= 18; index += 1) {
const grade = ["A", "B", "C"][(index - 1) % 3];
mentors.push({
id: `extra-${index}`,
name: `扩展模型${String(index).padStart(2, "0")}`,
description: `用于验证完整目录密度的${grade}级思维模型`,
tagline: "保持证据边界。",
focus: ["市场结构", "条件预案"],
evidence: { grade, label: { A: "原始语料", B: "多源整理", C: "行为材料" }[grade], note: `${grade}级测试素材` },
quality: { score: 6, total: 6, status: "pass" },
private: false,
});
}
if (role === "admin") mentors.unshift({
id: "private-owner",
name: "私有老师",
description: "依据个人复盘记录提炼",
tagline: "只对自己开放。",
focus: ["个人复盘", "交易纪律"],
evidence: { grade: "A", label: "私有原始语料", note: "仅限管理员本人使用" },
quality: { score: null, total: null, status: "private" },
private: true,
});
return mentors;
}
async function mockApplication(page, authSession = session(), options = {}) {
installApplicationNavigation(page);
let screenerTracking = {
batches: [{
run_id: 44,
selection_date: "20260721",
strategy_name: "Repair confirmation",
items: [{
id: 9,
code: "002141",
name: "Test Stock",
entry_price: 10,
t1_open: 1.2,
t1_close: 2.1,
t3_close: null,
t5_close: null,
max_gain: 3.4,
max_drawdown: -1.1,
observed_days: 1,
status: "tracking",
}],
}],
summary: { total: 1, observed: 1, t1_win_rate: 100, t5_win_rate: null, average_t5: null },
};
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url());
let payload = { ok: true };
if (url.pathname === "/api/auth/me") payload = authSession;
else if (url.pathname === "/api/dashboard") {
options.dashboardRequests = (options.dashboardRequests || 0) + 1;
options.dashboardTradeDates ||= [];
options.dashboardTradeDates.push(url.searchParams.get("trade_date"));
if (options.dashboardDelay) {
await new Promise((resolve) => setTimeout(resolve, options.dashboardDelay));
}
if (options.echoDashboardDate) {
const requestedDate = url.searchParams.get("trade_date");
payload = { ...dashboard, meta: { ...dashboard.meta, trade_date: requestedDate, requested_date: requestedDate } };
} else payload = dashboard;
}
else if (url.pathname === "/api/stock/002141/preview") {
if (options.previewDelay) {
await new Promise((resolve) => setTimeout(resolve, options.previewDelay));
}
payload = {
meta: { trade_date: "2026-07-23", intraday_trade_date: "2026-07-24", realtime: true, intraday_notice: "" },
stock: { code: "002141", name: "Test Stock", industry: "Test Sector", price: 10.8, change: 2.4 },
prices: [
{ trade_date: "2026-07-22", open: 10, high: 10.5, low: 9.9, close: 10.2, volume: 1000 },
{ trade_date: "2026-07-23", open: 10.3, high: 10.9, low: 10.2, close: 10.8, volume: 1200 },
],
intraday: [
{ date: "2026-07-24", time: "09:30", open: 10.20, high: 10.24, low: 10.18, close: 10.22, volume: 100, average: 10.22 },
{ date: "2026-07-24", time: "09:31", open: 10.22, high: 10.30, low: 10.21, close: 10.28, volume: 130, average: 10.25 },
{ date: "2026-07-24", time: "09:32", open: 10.28, high: 10.29, low: 10.20, close: 10.23, volume: 90, average: 10.24 },
{ date: "2026-07-24", time: "09:33", open: 10.23, high: 10.34, low: 10.22, close: 10.32, volume: 160, average: 10.27 },
{ date: "2026-07-24", time: "09:34", open: 10.32, high: 10.36, low: 10.29, close: 10.34, volume: 120, average: 10.28 },
],
};
} else if (url.pathname === "/api/stock/002141") {
payload = {
meta: { trade_date: "2026-07-23", realtime: false },
stock: { code: "002141", name: "Test Stock", industry: "Test Sector", price: 10.8, change: 2.4 },
prices: [
{ trade_date: "2026-07-22", open: 10, high: 10.5, low: 9.9, close: 10.2, volume: 1000 },
{ trade_date: "2026-07-23", open: 10.3, high: 10.9, low: 10.2, close: 10.8, volume: 1200 },
],
moneyflow: {},
notes: [],
};
} else if (url.pathname === "/api/search/detail") {
const theme = url.searchParams.get("type") === "theme";
payload = theme ? {
meta: { trade_date: "2026-07-23", realtime: false },
entity: { id: "885728.TI", code: "885728.TI", name: "人工智能", type: "theme", type_label: "题材", value: 1280, change: 2.2 },
series: [
{ trade_date: "2026-07-22", open: 1220, high: 1260, low: 1210, close: 1250, volume: 1000 },
{ trade_date: "2026-07-23", open: 1255, high: 1290, low: 1248, close: 1280, volume: 1200 },
],
metrics: [],
} : {
meta: { trade_date: "2026-07-23", realtime: false },
entity: { id: "000001.SH", code: "000001.SH", name: "上证指数", type: "index", type_label: "指数", value: 3800, change: 0.5 },
series: [
{ trade_date: "2026-07-22", open: 3750, high: 3790, low: 3740, close: 3780, volume: 1000 },
{ trade_date: "2026-07-23", open: 3782, high: 3810, low: 3770, close: 3800, volume: 1200 },
],
metrics: [],
};
} else if (url.pathname === "/api/chart/intraday") {
payload = {
meta: { trade_date: "2026-07-24", previous_close: 10.1 },
entity: { id: url.searchParams.get("id"), type: url.searchParams.get("type") },
points: [
{ date: "2026-07-24", time: "09:30", open: 10.10, high: 10.18, low: 10.08, close: 10.15, volume: 100, average: 10.15 },
{ date: "2026-07-24", time: "09:31", open: 10.15, high: 10.24, low: 10.14, close: 10.22, volume: 130, average: 10.18 },
{ date: "2026-07-24", time: "09:32", open: 10.22, high: 10.23, low: 10.16, close: 10.18, volume: 90, average: 10.18 },
],
};
} else if (url.pathname === "/api/watchlist") {
payload = { items: [{
code: "000002", name: "Watch Stock", sector: "Bank", color: "red",
change: 1.86, return_5d: 8.92, attention_score: 72.4,
remark: "观察承接,不追高", market_date: "20260722",
}] };
} else if (url.pathname === "/api/notes") {
payload = { items: [{
id: 12, code: "", stock_name: "", trade_date: "20260722",
summary: "缩量修复,主线仍待确认", content: "做对了等待确认。", plan: "只做有承接的核心。",
}] };
} else if (url.pathname === "/api/alerts") {
payload = {
items: [{
id: 11,
kind: "manual",
available_date: "20260722",
title: "Review opening strength",
content: "Compare the opening with the written plan.",
code: "002141",
is_read: false,
due: true,
}],
unread_count: 1,
};
} else if (url.pathname === "/api/trades") {
payload = {
items: [{
id: 7,
trade_date: "20260722",
code: "002141",
name: "Test Stock",
action: "buy",
action_label: "Buy",
price: 10.2,
quantity: 1000,
position_pct: 20,
pnl_amount: null,
pnl_pct: null,
emotion: "calm",
emotion_label: "Calm",
tags: ["planned"],
thesis: "Strength confirmed after the open.",
execution: "Executed within the planned range.",
}],
summary: { total: 1, realized: 0, win_rate: null, pnl_amount: null, average_position: 20 },
};
} else if (url.pathname === "/api/assistant/messages") {
payload = {
items: [{ role: "assistant", content: "Review evidence before forming a conclusion.", context_date: "20260722" }],
};
} else if (url.pathname === "/api/search") payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "Test Stock", type: "stock", industry: "Test Sector" }], sectors: [], themes: [], indices: [] } };
else if (url.pathname === "/api/dragon-tiger/profiles") {
payload = {
meta: { status: "success", source: "tushare", cached: true },
summary: { profile_count: 3, described_count: 2, organization_count: 4 },
profiles: [
{ id: "hot-money-profile-1", name: "赵老哥", description: "聚焦市场核心标的。", organizations: ["华泰证券浙江分公司", "银河证券绍兴"], organization_count: 2 },
{ id: "hot-money-profile-2", name: "炒股养家", description: "重视情绪与风险收益比。", organizations: ["华鑫证券上海宛平南路"], organization_count: 1 },
{ id: "hot-money-profile-3", name: "作手新一", description: "", organizations: ["国泰海通证券南京太平南路"], organization_count: 1 },
],
};
}
else if (url.pathname === "/api/dragon-tiger") {
payload = {
meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "empty", source: "tushare" },
summary: {},
traders: [],
unclassified_seats: [],
};
} else if (url.pathname === "/api/sentiment/history") payload = { rows: [], components: [] };
else if (url.pathname === "/api/rotation/history") payload = { days: [], rows: [], sectors: [] };
else if (url.pathname === "/api/rotation/members") {
payload = {
meta: { trade_date: "20260724", sector_name: url.searchParams.get("sector") || "电网设备", member_count: 3, quoted_count: 2 },
rows: [
{ code: "002879", name: "长缆科技", change: 4.8, open: 18.21, close: 19.06, amount_billion: 19.6, quoted: true },
{ code: "603221", name: "爱丽家居", change: 1.2, open: 13.05, close: 13.22, amount_billion: 8.7, quoted: true },
{ code: "000001", name: "停牌样本", change: null, open: null, close: null, amount_billion: null, quoted: false },
],
};
}
else if (url.pathname === "/api/auction") {
payload = {
meta: { trade_date: "2026-07-22", carried_forward: false, phase: "finalized", available: true, actionable: false },
summary: { stock_count: 3, candidate_count: 1, focus_count: 1, one_price_count: 1, up_count: 2, down_count: 1, limit_open_count: 1, strong_open_count: 2, median_change: 1.2, amount_billion: 2.5, amount_change_previous: 12.5, amount_change_5d: 8.2 },
expectations: { "超预期": 1, "符合预期": 0, "低于预期": 0 },
candidate_meta: { baseline_date: "2026-07-21" },
themes: {
carry: [{ name: "Test Sector", status: "强承接", prior_limit_count: 2, leader: "Test Stock", matched_count: 1, median_change: 4.2, positive_rate: 100, amount_million: 15 }],
new_themes: [{ name: "人工智能", stock_count: 2, median_change: 3.5, amount_million: 26, leaders: ["Test Stock"] }],
},
amount_history: [
{ trade_date: "2026-07-21", amount_billion: 2.2, stock_count: 2 },
{ trade_date: "2026-07-22", amount_billion: 2.5, stock_count: 2 },
],
news_feedback: { available: false, message: "隔夜消息反馈暂不可用", detail: "待稳定的新闻与公告数据接入后开放" },
focus_rows: [
{ code: "002141", name: "Test Stock", sector: "Test Sector", change: 4.2, price: 10.2, amount_million: 15, volume_ratio: 1.8, turnover_rate: 0.12, source_label: "昨日涨停 · 同花顺热榜", expectation: "超预期", expected_change: 2.2, attention_score: 88.5, core_tags: ["人气前5"], expectation_reason: "昨日首板;竞价涨幅高于预期中枢2.0个百分点,量比1.80" },
],
one_price_rows: [
{ code: "000001", name: "Limit Stock", sector: "Test Sector", change: 10, price: 11, amount_million: 8, volume_ratio: 3.2, source_label: "昨日涨停", prior_streak: 3, core_tags: ["三板以上"], is_market_core: true, is_one_price: true },
],
watchlist_rows: [
{ code: "000002", name: "Watch Stock", sector: "Bank", change: -1.2, price: 9.88, amount_million: 3, volume_ratio: 0.9, expectation: "符合预期", expected_change: 0, attention_score: 32.5, core_tags: [], expectation_reason: "自选观察;竞价反馈接近个人观察基准", is_watchlist: true, available: true },
],
watchlist_missing_count: 0,
rows: [
{ code: "002141", name: "Test Stock", sector: "Test Sector", change: 4.2, price: 10.2, amount_million: 15, volume_ratio: 1.8, turnover_rate: 0.12, source_label: "昨日涨停 · 同花顺热榜", expectation: "超预期", expected_change: 2.2, attention_score: 88.5, core_tags: ["人气前5"], expectation_reason: "昨日首板;竞价涨幅高于预期中枢2.0个百分点,量比1.80" },
],
};
} else if (url.pathname === "/api/themes") {
payload = {
meta: { trade_date: "2026-07-22", carried_forward: false },
summary: { theme_count: 1, quoted_count: 1, up_count: 1, down_count: 0, hot_count: 1 },
items: [{ code: "885728.TI", name: "人工智能", member_count: 1, change: 2.2, turnover_rate: 3.1, hot_rank: 1, has_quote: true }],
};
} else if (url.pathname === "/api/themes/detail") {
payload = {
meta: { trade_date: "2026-07-22" },
theme: { code: "885728.TI", name: "人工智能", member_count: 1, change: 2.2, turnover_rate: 3.1 },
summary: { member_count: 1, quoted_count: 1, up_count: 1, down_count: 0 },
series: [
{ trade_date: "2026-07-21", open: 100, high: 104, low: 99, close: 103, change: 3, volume: 1000 },
{ trade_date: "2026-07-22", open: 103, high: 106, low: 102, close: 105, change: 1.94, volume: 1200 },
],
members: [{ code: "002141", name: "Test Stock", change: 2.4, price: 10.8, amount_billion: 3.2, has_quote: true }],
};
} else if (url.pathname === "/api/popularity") {
const hot = { rank: 1, code: "002141", ts_code: "002141.SZ", name: "Test Stock", change: 2.4, price: 10.8, ths_rank: 1, dc_rank: 2, rank_change: 3, concepts: ["人工智能"], dual_source: true };
payload = { meta: { trade_date: "2026-07-22", carried_forward: false }, summary: { ths_count: 1, dc_count: 1, dual_count: 1 }, combined: [hot], ths: [{ ...hot, rank: 1 }], dc: [{ ...hot, rank: 2 }] };
}
else if (url.pathname === "/api/screener/setup") {
options.screenerSetupRequests = (options.screenerSetupRequests || 0) + 1;
const factorFields = [
["amount_billion", "成交额(亿元)"], ["above_ma20", "站上20日线"],
["relative_strength", "相对强度"], ["sector_strength", "板块强度"],
["volume_ratio_5d", "5日量比"], ["volatility_10d", "10日波动率"],
["dividend_yield_ttm", "股息率TTM"],
].map(([id, label]) => ({ id, label }));
payload = {
trade_date: "20260722",
regime: { id: "repair", label: "修复", confidence: 70, reason: "测试", evidence: [] },
regimes: [{ id: "repair", label: "修复" }],
factor_fields: factorFields,
factor_groups: [{ name: "行情与质量", fields: factorFields }],
operators: [">", ">=", "<", "<=", "==", "between"],
factor_data: {
ready: true,
date_count: 45,
start_date: "20260518",
end_date: "20260722",
health: { market: true, auction: true, valuation: true, fundamental: true, dividend_history: true },
},
llm: { configured: true },
strategies: [
{
id: 1, name: "修复确认", description: "保留原有智能策略流程", regimes: ["repair"], builtin: true,
data_ready: true, missing_data: [],
formula: {
meta: { library: "smart" }, universe: { exclude_st: true, listed_days_min: 120 }, filters: [],
score: [{ field: "relative_strength", weight: 1, direction: "desc" }], limit: 15, min_score: 0.5,
},
},
{
id: 2, name: "连续分红质量", description: "持续分红、估值与流动性共同约束。", regimes: ["repair"], builtin: true,
data_ready: true, missing_data: [],
formula: {
meta: { library: "curated", category: "红利价值", quality: "A", frequency: "月度", risk: "中低", data_group: "估值与财务" },
universe: { exclude_st: true, listed_days_min: 720 },
filters: [{ field: "dividend_yield_ttm", op: ">=", value: 2 }],
score: [{ field: "dividend_yield_ttm", weight: 1, direction: "desc" }], limit: 20, min_score: 0.5,
},
},
],
};
if (options.additionalScreenerRegimes) {
payload.regimes.push(...options.additionalScreenerRegimes);
}
if (options.additionalScreenerStrategies) {
payload.strategies.push(...options.additionalScreenerStrategies);
}
if (options.latestScreenerResults) {
payload.latest_results = options.latestScreenerResults;
payload.latest_result = options.latestScreenerResults.smart || null;
}
if (options.recentScreenerResults) {
payload.recent_results = options.recentScreenerResults;
}
if (options.screenerPublishedRuns) {
for (const strategy of payload.strategies) {
const publishedRun = options.screenerPublishedRuns[strategy.name];
if (publishedRun) strategy.published_run = publishedRun;
}
}
if (options.screenerSetupOverride) {
payload = { ...payload, ...options.screenerSetupOverride };
}
} else if (url.pathname === "/api/screener/run") {
const body = route.request().postDataJSON();
options.screenerRunBodies = [...(options.screenerRunBodies || []), body];
const runResult = options.screenerRunResult?.(body);
payload = {
result: runResult || options.latestScreenerResults?.[body.mode] || {
meta: {
run_id: 99,
trade_date: "20260722",
regime: body.regime,
strategy_name: body.strategy_name,
mode: body.mode,
},
candidates: [],
disclaimer: "历史统计不代表未来收益",
backtest: null,
},
};
if (options.recentScreenerResults) {
options.recentScreenerResults.unshift(payload.result);
}
} else if (url.pathname === "/api/screener/tracking") {
if (route.request().method() === "POST") {
const body = route.request().postDataJSON();
screenerTracking = {
batches: [...screenerTracking.batches, {
run_id: body.run_id,
selection_date: "20260722",
strategy_name: "Manual strategy",
items: [{ id: 10, code: body.code, name: "Manual Stock", entry_price: 12, observed_days: 0, status: "等待 T+1" }],
}],
summary: { ...screenerTracking.summary, total: screenerTracking.summary.total + 1 },
};
payload = { ok: true, tracking: screenerTracking };
} else payload = screenerTracking;
} else if (/^\/api\/screener\/tracking\/\d+$/.test(url.pathname)) {
const trackId = Number(url.pathname.split("/").at(-1));
screenerTracking = {
batches: screenerTracking.batches.map((batch) => ({
...batch,
items: batch.items.filter((item) => item.id !== trackId),
})).filter((batch) => batch.items.length),
summary: { ...screenerTracking.summary, total: Math.max(0, screenerTracking.summary.total - 1) },
};
payload = { ok: true, deleted: true, tracking: screenerTracking };
} else if (url.pathname === "/api/heaven/readings") {
payload = {
mode: url.searchParams.get("mode") || "fortune",
items: [{
id: 31,
mode: "fortune",
context_date: "20260723",
subject: "2026-07-23 观气",
subject_detail: "丙午年 · 乙未月 · 己丑日 · 土气偏显",
answer: "三层气机已经合参,今日宜先定节奏,再看行动。",
created_at: "2026-07-23T09:12:00+08:00",
}],
};
} else if (url.pathname === "/api/mentors/setup") {
payload = { trade_date: "20260722", mentors: options.mentors || mentorDirectory(authSession.user.role) };
} else if (url.pathname === "/api/mentors/chat") {
await route.fulfill({
status: 200,
contentType: "application/x-ndjson; charset=utf-8",
body: [
JSON.stringify({ type: "delta", content: "## 判断\n先看市场结构。\n\n" }),
JSON.stringify({ type: "delta", content: "- 等待确认\n- 控制仓位" }),
JSON.stringify({
type: "meta",
data_trade_date: "20260722",
notice: "",
follow_ups: ["哪些信号代表确认?", "这个判断在什么情况下失效?", "空仓时应该先观察什么?"],
}),
JSON.stringify({ type: "done" }),
].join("\n"),
});
return;
}
else if (url.pathname === "/api/heaven/setup") {
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "测试环境不加载问天数据" }) });
return;
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(payload) });
});
}
async function openHeaderCommandMenu(page) {
const menu = page.locator("#headerCommandGroup");
if (await menu.isVisible()) return;
await page.locator("#headerMenuButton").click();
await expect(menu).toBeVisible();
}
function namedSession(username, role = "admin", subscribed = true) {
const auth = session(role, subscribed);
auth.user.username = username;
return auth;
}
async function setColorTheme(page, theme) {
const wanted = theme === "night" ? "dark" : "light";
const current = await page.locator("html").getAttribute("data-theme");
if (current === wanted) return;
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", wanted);
}
function measureAccountName() {
const name = document.querySelector("#accountName");
const button = document.querySelector("#accountButton");
const badges = document.querySelector("#accountRoleBadges");
const adminLabel = document.querySelector("#accountAdminBadge > span");
const vipLabel = document.querySelector("#accountVipLabel");
const box = (node) => node ? node.getBoundingClientRect() : null;
const visible = (node) => {
if (!node || node.hidden) return false;
const style = getComputedStyle(node);
if (style.display === "none" || style.visibility === "hidden") return false;
const rect = node.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const buttonBox = box(button);
const badgeBox = box(badges);
return {
text: name ? String(name.textContent || "") : "",
clientWidth: name ? name.clientWidth : 0,
scrollWidth: name ? name.scrollWidth : 0,
fits: name ? name.clientWidth >= name.scrollWidth : false,
buttonFits: button ? button.clientWidth >= button.scrollWidth : false,
stacked: Boolean(badgeBox && buttonBox && buttonBox.top >= badgeBox.bottom - 1),
adminLabelVisible: visible(adminLabel),
vipLabelVisible: visible(vipLabel),
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
};
}
test("admin shell opens every primary workspace and global search", async ({ page }) => {
await mockApplication(page);
await page.goto("/index.html");
await expect(page.locator("#authGate")).toBeHidden();
await openHeaderCommandMenu(page);
await expect(page.locator("#settingsButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeVisible();
await page.keyboard.press("Escape");
await page.locator("#alertButton").click();
await expect(page.locator("#alertsDialog")).toBeVisible();
await page.locator("#closeAlertsDialog").click();
await page.locator("#assistantButton").click();
await expect(page.locator("#assistantDialog")).toBeVisible();
await page.locator("#closeAssistantDialog").click();
const views = [
"auctionView", "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView",
"performanceView", "ladderView", "rotationView", "themeLibraryView", "popularityView", "dragonView", "screenerView",
"heavenView", "reviewWorkspaceView", "mentorView",
];
for (const view of views) {
await page.locator(`[data-view="${view}"]`).first().click();
await expect(page.locator(`#${view}`)).toHaveClass(/active-view/);
await expect(page.locator(".module-tab.active")).toHaveCount(1);
}
await page.keyboard.press("Control+K");
await expect(page.locator("#globalSearchDialog")).toBeVisible();
await expect(page.locator("#globalSearchInput")).toBeFocused();
});
test("fresh visits default to the latest date and sentiment cycle", async ({ page }) => {
const options = { echoDashboardDate: true };
await mockApplication(page, session("admin", true), options);
await page.goto("/index.html?date=2026-07-28");
const today = await page.evaluate(() => todayString());
await expect(page.locator("#tradeDate")).toHaveValue(today);
await expect(page.locator("#sentimentCycleView")).toHaveClass(/active-view/);
await expect(page.locator('[data-view="sentimentCycleView"]')).toHaveClass(/active/);
expect(options.dashboardTradeDates.at(-1)).toBe(today);
expect(new URL(page.url()).searchParams.has("date")).toBe(false);
await page.evaluate(() => {
const input = document.querySelector("#tradeDate");
input.value = "2026-07-28";
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await expect.poll(() => options.dashboardTradeDates.at(-1)).toBe("2026-07-28");
expect(new URL(page.url()).searchParams.has("date")).toBe(false);
});
test("every primary workspace shares the canonical desktop shell geometry", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
const views = [
"auctionView", "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView",
"performanceView", "ladderView", "rotationView", "themeLibraryView", "popularityView", "dragonView",
"screenerView", "heavenView", "reviewWorkspaceView",
];
let reference = null;
for (const view of views) {
await page.locator(`[data-view="${view}"]`).first().click();
const activeView = page.locator(`#${view}`);
await expect(activeView).not.toHaveClass(/view-entering/);
const box = await activeView.boundingBox();
expect(box).not.toBeNull();
reference ||= { x: box.x, y: box.y, width: box.width };
expect(Math.abs(box.x - reference.x)).toBeLessThanOrEqual(1);
expect(Math.abs(box.y - reference.y)).toBeLessThanOrEqual(1);
expect(Math.abs(box.width - reference.width)).toBeLessThanOrEqual(1);
}
});
test("manual refresh stays in place without reopening the full-page loader", async ({ page }) => {
const options = { dashboardDelay: 350 };
await mockApplication(page, session(), options);
await page.goto("/index.html");
await expect(page.locator("#loadingOverlay")).toBeHidden();
await openHeaderCommandMenu(page);
await page.locator("#refreshButton").click();
await expect(page.locator("#refreshButton")).toBeDisabled();
await expect(page.locator("#loadingOverlay")).toBeHidden();
await expect(page.locator("#statusText")).toContainText("刷新");
await expect(page.locator("#refreshButton")).toBeEnabled();
expect(options.dashboardRequests).toBe(2);
});
test("night mode covers the application shell and persists across reloads", async ({ page }) => {
await mockApplication(page, session("admin", true));
await page.addInitScript(() => {
if (sessionStorage.getItem("themeTestReady")) return;
localStorage.removeItem("xiaobaiTheme");
sessionStorage.setItem("themeTestReady", "1");
});
await page.goto("/index.html");
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "false");
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到日间模式");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "true");
const darkSurfaces = await page.evaluate(() => {
const color = (selector) => getComputedStyle(document.querySelector(selector)).backgroundColor;
return {
body: color("body"),
sidebar: color(".module-nav"),
topbar: color(".app-header"),
tableHead: color("#limitTable thead th"),
};
});
expect(new Set(Object.values(darkSurfaces)).has("rgb(255, 255, 255)")).toBe(false);
await page.reload();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "true");
await page.keyboard.press("Control+K");
await expect(page.locator("#globalSearchDialog")).toBeVisible();
expect(await page.locator("#globalSearchDialog").evaluate((dialog) => getComputedStyle(dialog).backgroundColor)).not.toBe("rgb(255, 255, 255)");
await page.locator("#closeGlobalSearch").click();
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到夜间模式");
});
test("collapsed overview and sentiment layout keep a single current reading", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await expect(page.locator("#sentimentGauge")).toBeHidden();
await page.evaluate(() => {
state.sentimentHistory = {
available_days: 20,
rows: [{
trade_date: "2026-07-22", score: 32, label: "情绪偏弱", phase: "退潮", direction: "降温",
day_change: -12, seal_rate: 65, limit_up_count: 32, first_board_count: 20,
second_board_count: 6, three_plus_count: 3, max_height: 4, broken_count: 17,
limit_down_count: 25, previous_limit_count: 40, previous_positive_count: 12,
previous_positive_rate: 30, average_previous_change: -1.2, normalization: "固定锚点",
components: {
breadth: { label: "市场宽度", score: 28, weight: 20, summary: "红盘家数偏少" },
limit: { label: "涨停生态", score: 42, weight: 25, summary: "封板率仍需确认" },
profit: { label: "赚钱效应", score: 31, weight: 30, summary: "昨日反馈偏弱" },
ladder: { label: "连板结构", score: 38, weight: 15, summary: "高度仍在压缩" },
amount: { label: "成交活跃度", score: 25, weight: 10, summary: "量能低于均值" },
},
}],
};
renderSentimentHistory();
});
await page.locator('[data-view="sentimentCycleView"]').first().click();
await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0);
await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。");
const alignment = await page.evaluate(() => {
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect();
const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect();
const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect();
const label = document.querySelector(".sentiment-block .metric-label");
const status = document.querySelector(".sentiment-block .sentiment-text");
const labelStyle = getComputedStyle(document.querySelector(".sentiment-block .metric-label"));
const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text"));
return {
columnsAligned: Math.abs(trend.top - summary.top) < 1,
railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom,
detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom),
chartHeight: chart.height,
sameType: labelStyle.fontSize === statusStyle.fontSize
&& labelStyle.fontWeight === statusStyle.fontWeight
&& labelStyle.lineHeight === statusStyle.lineHeight,
sameBaseline: Math.abs(label.getBoundingClientRect().y - status.getBoundingClientRect().y) < 0.1,
noStatusOffset: statusStyle.marginTop === "0px",
};
});
expect(alignment.columnsAligned).toBe(true);
expect(alignment.railAligned).toBe(true);
expect(alignment.detailAfterAnalysis).toBe(true);
expect(alignment.chartHeight).toBeGreaterThanOrEqual(340);
expect(alignment.sameType).toBe(true);
expect(alignment.sameBaseline).toBe(true);
expect(alignment.noStatusOffset).toBe(true);
const pageFrames = {};
for (const [view, headSelector] of [
["sentimentCycleView", ".sentiment-cycle-toolbar"],
["auctionView", ".auction-page-head-v2"],
["themeLibraryView", ".theme-page-head-v2"],
["popularityView", ".popularity-page-head-v2"],
["dragonView", ".dragon-page-head-v2"],
]) {
await page.locator(`[data-view="${view}"]`).first().click();
await page.waitForTimeout(350);
pageFrames[view] = await page.evaluate(({ view, headSelector }) => {
const workspace = document.getElementById(view);
const frame = workspace.getBoundingClientRect();
const head = workspace.querySelector(headSelector).getBoundingClientRect();
const style = getComputedStyle(workspace);
return {
frame: [Math.round(frame.x), Math.round(frame.y), Math.round(frame.width)],
head: [Math.round(head.x), Math.round(head.y), Math.round(head.width)],
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
background: style.backgroundColor,
border: style.borderTopWidth,
};
}, { view, headSelector });
}
const sentimentFrame = JSON.stringify(pageFrames.sentimentCycleView);
for (const view of ["auctionView", "themeLibraryView", "popularityView", "dragonView"]) {
expect(JSON.stringify(pageFrames[view])).toBe(sentimentFrame);
}
});
test("limit-up pool separates stock identity and restores the reason column", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.evaluate(() => {
state.dashboard.limits = [{
code: "603221", name: "爱丽家居", streak: 4, change: 9.98, price: 14,
sector: "家居用品", first_time: "09:25:01", last_time: "09:25:01",
open_times: 0, turnover_rate: 0.41, amount_billion: 1.2,
seal_amount_million: 5200, reason: "家居消费方向走强",
}];
renderLimitTable();
});
await page.locator('[data-view="limitPool"]').first().click();
expect((await page.locator("#limitTable thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([
"序号", "股票", "连板", "涨幅(%", "价格(元)", "所属板块", "首封", "最后封板", "开板(次)", "换手率(%", "成交额(亿)", "封单额(万)", "涨停原因",
]);
const cells = page.locator("#limitTableBody tr").first().locator("td");
await expect(cells).toHaveCount(13);
await expect(cells.nth(1)).toContainText("爱丽家居");
await expect(cells.nth(1)).toContainText("603221");
await expect(cells.nth(12)).toHaveText("家居消费方向走强");
for (const [view, table, reasonLabel] of [
["limitPool", "limitTable", "涨停原因"],
["brokenView", "brokenTable", "炸板原因"],
["downView", "downTable", "风险线索"],
["yesterdayView", "yesterdayTable", "涨停逻辑"],
]) {
await page.locator(`[data-view="${view}"]`).first().click();
const geometry = await page.locator(`#${table}`).evaluate((tableNode, reason) => {
const headers = [...tableNode.tHead.rows[0].cells];
const widths = headers.map((header) => header.getBoundingClientRect().width);
const reasonIndex = headers.findIndex((header) => header.textContent.trim() === reason);
const numeric = headers.map((header, index) => ({ header, index })).filter(({ header }) => header.classList.contains("num"));
return {
index: widths[0],
reason: widths[reasonIndex],
numericMax: Math.max(...numeric.map(({ index }) => widths[index])),
numericAligned: numeric.every(({ header }) => getComputedStyle(header).textAlign === "right"),
numericVariant: numeric.every(({ header }) => getComputedStyle(header).fontVariantNumeric.includes("tabular-nums")),
};
}, reasonLabel);
expect(geometry.reason).toBeGreaterThan(geometry.numericMax);
expect(geometry.index).toBeLessThan(geometry.numericMax);
expect(geometry.numericAligned).toBe(true);
expect(geometry.numericVariant).toBe(true);
}
});
test("broken pool matches the approved table structure and keeps independent interactions", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.evaluate(() => {
state.dashboard.broken = [
{ code: "002156", name: "通富微电", change: 9.77, price: 76.64, sector: "半导体", first_time: "10:08:03", open_times: 4, turnover_rate: 17.36, amount_billion: 198.18, reason: "芯片方向冲高回落" },
{ code: "300214", name: "日科化学", change: 15.59, price: 11.86, sector: "化学制品", first_time: "09:54:21", open_times: 1, turnover_rate: 19.02, amount_billion: 10.37, reason: "化工板块异动" },
{ code: "601678", name: "滨化股份", change: 5.14, price: 6.54, sector: "化学原料", first_time: "09:35:55", open_times: 8, turnover_rate: 25.23, amount_billion: 34.64, reason: "高位反复开板" },
];
renderBrokenTable(state.dashboard.broken);
});
await page.locator('[data-view="brokenView"]').first().click();
await expect(page.locator("#brokenView")).toHaveClass(/redesigned-broken-view/);
expect((await page.locator("#brokenTable thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([
"序号", "股票", "现价涨幅(%", "距涨停(%", "价格(元)", "所属板块", "首次触板", "开板(次)", "换手率(%", "成交额(亿)", "炸板原因",
]);
await expect(page.locator("#brokenTableBody tr")).toHaveCount(3);
await expect(page.locator("#brokenTableBody tr").nth(0)).toContainText("0.23");
await expect(page.locator("#brokenTableBody tr").nth(1)).toContainText("4.41");
await expect(page.locator("#brokenTableBody tr").nth(2)).toContainText("反复炸 ×8");
await expect(page.locator("#brokenTableBody")).toContainText("芯片方向冲高回落");
await page.locator("#brokenSearch").fill("半导体");
await expect(page.locator("#brokenTableBody tr")).toHaveCount(1);
await expect(page.locator("#brokenTableBody")).toContainText("通富微电");
await page.locator("#brokenSearch").fill("");
await page.locator('[data-broken-sort="amount_billion"]').click();
await expect(page.locator("#brokenTableBody tr").first()).toContainText("通富微电");
await page.locator('[data-broken-sort="amount_billion"]').click();
await expect(page.locator("#brokenTableBody tr").first()).toContainText("日科化学");
await expect(page.locator("#brokenCount")).toHaveText("3 只");
await expect(page.locator("#brokenMeta")).toContainText("数据日期 2026-07-22");
expect(await page.evaluate(() => [
brokenLimitRate({ code: "600000", name: "普通股票" }),
brokenLimitRate({ code: "300001", name: "创业板股票" }),
brokenLimitRate({ code: "830001", name: "北交所股票" }),
brokenLimitRate({ code: "300001", name: "ST测试" }),
])).toEqual([10, 20, 30, 10]);
});
test("down-limit pool matches the approved risk-cluster table structure", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.evaluate(() => {
state.dashboard.down_limits = [
{ code: "000037", name: "深南电A", change: -10.03, price: 8.97, sector: "电力", turnover_rate: 11.78, amount_billion: 3.68, streak: 2, reason: "连续弱势跌停" },
{ code: "000539", name: "粤电力A", change: -10.02, price: 5.66, sector: "电力", turnover_rate: 5.48, amount_billion: 8.16, streak: 1, reason: "板块集中释放风险" },
{ code: "001896", name: "豫能控股", change: -10.01, price: 14.39, sector: "电力", turnover_rate: 9.09, amount_billion: 20.64, streak: 1, reason: "高位补跌" },
{ code: "300045", name: "华力创通", change: -20.03, price: 11.46, sector: "军工电子", turnover_rate: 9.54, amount_billion: 5.76, streak: 1, reason: "放量破位" },
];
renderDownTable(state.dashboard.down_limits);
});
await page.locator('[data-view="downView"]').first().click();
await expect(page.locator("#downView")).toHaveClass(/redesigned-down-view/);
expect((await page.locator("#downTable thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([
"序号", "股票", "跌幅(%", "价格(元)", "所属板块", "换手率(%", "成交额(亿)", "连续跌停(天)", "风险线索",
]);
await expect(page.locator("#downTableBody tr")).toHaveCount(4);
await expect(page.locator("#downSectorCluster")).toHaveText("电力集中跌停 ×3");
await expect(page.locator("#downSectorCluster")).toBeVisible();
await expect(page.locator("#downTableBody tr").first()).toContainText("2");
await expect(page.locator("#downTableBody")).toContainText("连续弱势跌停");
await page.locator("#downSearch").fill("军工电子");
await expect(page.locator("#downTableBody tr")).toHaveCount(1);
await expect(page.locator("#downTableBody")).toContainText("华力创通");
await page.locator("#downSearch").fill("");
await page.locator('[data-down-sort="change"]').click();
await expect(page.locator("#downTableBody tr").first()).toContainText("华力创通");
await page.locator('[data-down-sort="amount_billion"]').click();
await expect(page.locator("#downTableBody tr").first()).toContainText("深南电A");
await expect(page.locator("#downCount")).toHaveText("4 只");
await expect(page.locator("#downMeta")).toContainText("数据日期 2026-07-22");
});
test("yesterday-limit pool exposes outcome summaries and combined filters", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.evaluate(() => {
state.dashboard.meta.previous_trade_date = "2026-07-21";
state.dashboard.yesterday_limits = [
{ code: "000011", name: "深物业A", prior_streak: 1, current_change: 9.99, outcome: "晋级", current_streak: 2, sector: "房地产开发", reason: "地产政策预期" },
{ code: "000533", name: "顺钠股份", prior_streak: 1, current_change: 10.02, outcome: "晋级", current_streak: 2, sector: "电网设备", reason: "电网设备走强" },
{ code: "000017", name: "深中华A", prior_streak: 1, current_change: 2.11, outcome: "断板", current_streak: 0, sector: "饰品", reason: "消费修复" },
{ code: "000035", name: "中国天楹", prior_streak: 1, current_change: -5.38, outcome: "断板", current_streak: 0, sector: "环境治理", reason: "环保题材" },
{ code: "001258", name: "立新能源", prior_streak: 6, current_change: 7.60, outcome: "炸板", current_streak: 0, sector: "电力", reason: "新能源核心" },
{ code: "001388", name: "信通电子", prior_streak: 1, current_change: -9.99, outcome: "跌停", current_streak: 0, sector: "电网设备", reason: "智能电网" },
];
renderYesterdayTable(state.dashboard.yesterday_limits);
});
await page.locator('[data-view="yesterdayView"]').first().click();
await expect(page.locator("#yesterdayView")).toHaveClass(/redesigned-yesterday-view/);
expect((await page.locator("#yesterdayTable thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([
"序号", "股票", "昨日高度(板)", "今日涨幅(%", "今日结果", "当前高度(板)", "所属板块", "涨停逻辑",
]);
await expect(page.locator("#yesterdayAllCount")).toHaveText("6");
await expect(page.locator("#yesterdayAdvanceCount")).toHaveText("2");
await expect(page.locator("#yesterdayAdvanceRate")).toHaveText("晋级率 33.3%");
await expect(page.locator("#yesterdayPositiveCount")).toHaveText("4");
await expect(page.locator("#yesterdayPositiveRate")).toHaveText("兑现率 66.7%");
await expect(page.locator("#yesterdayFailCount")).toHaveText("2");
await expect(page.locator("#yesterdayRiskCount")).toHaveText("2");
await expect(page.locator("#yesterdayRiskRate")).toHaveText("亏钱效应 33.3%");
await page.locator('[data-yesterday-filter="positive"]').click();
await expect(page.locator("#yesterdayTableBody tr")).toHaveCount(4);
await expect(page.locator("#yesterdayTableBody")).toContainText("深物业A");
await expect(page.locator("#yesterdayTableBody")).toContainText("立新能源");
await page.locator('[data-yesterday-filter="risk"]').click();
await expect(page.locator("#yesterdayTableBody tr")).toHaveCount(2);
await expect(page.locator("#yesterdayTableBody")).toContainText("立新能源");
await expect(page.locator("#yesterdayTableBody")).toContainText("信通电子");
await page.locator("#yesterdaySearch").fill("电网设备");
await expect(page.locator("#yesterdayTableBody tr")).toHaveCount(1);
await expect(page.locator("#yesterdayTableBody")).toContainText("信通电子");
await page.locator("#yesterdaySearch").fill("");
await page.locator('[data-yesterday-filter="all"]').click();
await page.locator('[data-yesterday-sort="current_change"]').click();
await expect(page.locator("#yesterdayTableBody tr").first()).toContainText("顺钠股份");
await expect(page.locator("#yesterdayTableBody tr").nth(2).locator("td").nth(5)).toBeEmpty();
await expect(page.locator("#yesterdayTableBody")).toContainText("地产政策预期");
await expect(page.locator("#yesterdayMeta")).toHaveText(" · 昨日 2026-07-21 → 今日 2026-07-22");
});
test("limit-up performance transfers the approved tier cards and market conclusion", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.evaluate(() => {
state.dashboard.meta.previous_trade_date = "2026-07-21";
state.dashboard.meta.updated_at = "2026-07-22T15:00:00+08:00";
state.dashboard.overview = {
...state.dashboard.overview,
up_count: 555,
flat_count: 0,
down_count: 4940,
limit_up_count: 40,
limit_down_count: 25,
sentiment_phase: "退潮",
};
state.dashboard.limit_performance = [
{ level: 6, label: "昨日6板", count: 1, advanced: 0, advance_rate: 0, positive_rate: 0, average_change: -9.8 },
{ level: 4, label: "昨日4板", count: 1, advanced: 0, advance_rate: 0, positive_rate: 0, average_change: -6.2 },
{ level: 3, label: "昨日3板", count: 4, advanced: 2, advance_rate: 50, positive_rate: 75, average_change: 3.4 },
{ level: 2, label: "昨日2板", count: 9, advanced: 2, advance_rate: 22.2, positive_rate: 44.4, average_change: 0.8 },
{ level: 1, label: "昨日首板", count: 101, advanced: 13, advance_rate: 12.9, positive_rate: 35.6, average_change: -1.2 },
];
renderPerformance(state.dashboard.limit_performance);
});
await page.locator('[data-view="performanceView"]').first().click();
await expect(page.locator("#performanceView")).toHaveClass(/redesigned-performance-view/);
await expect(page.locator("#performanceDateRange")).toHaveText("昨日 2026-07-21 → 今日 2026-07-22");
await expect(page.locator("#performanceCards .performance-stage-card")).toHaveCount(5);
await expect(page.locator("#performanceCards .performance-stage-card").first()).toContainText("昨日5板+ → 今日");
await expect(page.locator("#performanceCards .performance-stage-card").first()).toContainText("失效");
await expect(page.locator("#performanceCards .performance-stage-card").nth(2)).toContainText("50.0%");
await expect(page.locator("#performanceCards .performance-stage-card").nth(2)).toContainText("活跃");
await expect(page.locator("#performanceCards .performance-stage-card").nth(4)).toContainText("危险");
await expect(page.locator("#performanceTableBody")).toHaveCount(0);
await expect(page.locator("#breadthUpCount")).toHaveText("555");
await expect(page.locator("#breadthDownCount")).toHaveText("4,940");
await expect(page.locator("#breadthRatio")).toHaveText("10.1%");
await expect(page.locator("#breadthWarning")).toHaveText("△ 宽度极差,涨跌停 40:25");
await expect(page.locator("#performanceConclusion")).toContainText("高位晋级率全线失效");
await expect(page.locator("#performanceConclusion")).toContainText("昨日3板晋级率最高");
await expect(page.locator("#performanceConclusion")).toContainText("当前情绪周期「退潮」");
});
test("market ladder transfers tier bands, sorting and structural insights", async ({ page }) => {
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.evaluate(() => {
state.dashboard.meta.previous_trade_date = "2026-07-21";
state.dashboard.yesterday_limits = [{ prior_streak: 6 }, { prior_streak: 3 }];
state.dashboard.limit_performance = [
{ level: 4, label: "昨日3板", advance_rate: 50 },
{ level: 3, label: "昨日2板", advance_rate: 22.2 },
{ level: 2, label: "昨日首板", advance_rate: 12.9 },
];
state.dashboard.ladders = [
{ level: 4, label: "4板", count: 2, stocks: [
{ code: "002879", name: "长缆科技", sector: "电网设备", first_time: "09:25:00", open_times: 2, seal_amount_million: 7247 },
{ code: "603221", name: "爱丽家居", sector: "家居用品", first_time: "09:25:01", open_times: 0, seal_amount_million: 27572 },
] },
{ level: 3, label: "3板", count: 2, stocks: [
{ code: "000595", name: "新能股份", sector: "电力", first_time: "09:25:00", open_times: 14, seal_amount_million: 1239 },
{ code: "301234", name: "五洲医疗", sector: "医疗器械", first_time: "09:25:00", open_times: 0, seal_amount_million: 39679 },
] },
{ level: 2, label: "2板", count: 9, stocks: Array.from({ length: 9 }, (_, index) => ({
code: `000${String(index + 1).padStart(3, "0")}`, name: `二板股票${index + 1}`, sector: "电网设备", first_time: `09:3${index}:00`, open_times: index, amount_billion: 1.2,
})) },
{ level: 1, label: "首板", count: 3, stocks: [
{ code: "002374", name: "中锐股份", sector: "包装印刷", first_time: "09:31:33", open_times: 0, seal_amount_million: 5210 },
{ code: "300414", name: "中光防雷", sector: "通信设备", first_time: "09:34:42", open_times: 1, seal_amount_million: 3365 },
{ code: "002012", name: "凯恩股份", sector: "造纸", first_time: "09:35:45", open_times: 0, seal_amount_million: 2874 },
] },
];
renderLadderBoard(state.dashboard.ladders);
});
await page.locator('[data-view="ladderView"]').first().click();
await expect(page.locator("#ladderView")).toHaveClass(/redesigned-ladder-view/);
await expect(page.locator("#ladderDateRange")).toHaveText("数据日期 2026-07-22");
await expect(page.locator("#ladderBoard .market-ladder-tier")).toHaveCount(5);
await expect(page.locator("#ladderBoard .market-ladder-tier").first()).toContainText("5板+");
await expect(page.locator("#ladderBoard .market-ladder-tier").first()).toContainText("断层");
await expect(page.locator("#ladderBoard .market-ladder-stock")).toHaveCount(15);
await expect(page.locator("#ladderBoard .market-ladder-tag.one-price")).toHaveCount(2);
const equalCardWidths = await page.locator("#ladderBoard .market-ladder-stock").evaluateAll((cards) => cards.map((card) => card.getBoundingClientRect().width));
expect(Math.max(...equalCardWidths) - Math.min(...equalCardWidths)).toBeLessThan(1);
await expect(page.locator("#ladderInsights .market-ladder-apex-card")).toContainText("4 板");
await expect(page.locator("#ladderInsights")).toContainText("较昨日 6 板 ↓ 空间压缩");
await expect(page.locator("#ladderInsights .market-ladder-rate-list")).toContainText("50.0%");
await page.locator('[data-ladder-sort="open"]').click();
await expect(page.locator('[data-ladder-sort="open"]')).toHaveClass(/active/);
await expect(page.locator("#ladderBoard .market-ladder-tier").nth(2).locator(".market-ladder-stock").first()).toContainText("五洲医疗");
await page.locator('[data-ladder-level="2"]').click();
await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-stock")).toHaveCount(9);
await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-more")).toContainText("收起");
await page.locator('[data-ladder-level="2"]').click();
await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-stock")).toHaveCount(8);
await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-more")).toContainText("展开剩余 1 只");
const ladderOverflow = await page.evaluate(() => {
const group = state.dashboard.ladders.find((item) => item.level === 2);
group.stocks = Array.from({ length: 48 }, (_, index) => ({
code: `001${String(index).padStart(3, "0")}`,
name: `二板扩展${index + 1}`,
sector: "电网设备",
first_time: "09:30:00",
open_times: index % 4,
amount_billion: 1.2,
}));
group.count = group.stocks.length;
state.expandedLadderLevels.add(2);
renderLadderBoard(state.dashboard.ladders);
const main = document.querySelector(".app-main");
return {
clientHeight: main.clientHeight,
scrollHeight: main.scrollHeight,
overflowY: getComputedStyle(main).overflowY,
};
});
expect(ladderOverflow.overflowY).toBe("auto");
expect(ladderOverflow.scrollHeight).toBeGreaterThan(ladderOverflow.clientHeight);
});
test("sector rotation transfers the nine-day matrix, tracking and sortable detail", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.evaluate(() => {
const dates = ["20260714", "20260715", "20260716", "20260717", "20260720", "20260721", "20260722", "20260723", "20260724"];
state.dashboard.meta.trade_date = "20260724";
state.rotationHistory = {
rows: dates.map((tradeDate, dayIndex) => ({
trade_date: tradeDate,
sectors: [
{ name: "电网设备", rank: 1, count: 5 + dayIndex, strength: 92 },
{ name: "计算机设备与自动化设备", rank: 2, count: 3, strength: 76 },
{ name: `轮动板块${dayIndex + 1}`, rank: 3, count: 1, strength: 58 },
],
})),
};
state.rotationHistoryKey = `${document.querySelector("#tradeDate").value}:9`;
state.dashboard.sector_rotation = [
{ rank: 1, name: "电网设备", count: 8, previous_count: 3, delta: 5, strength: 96, max_streak: 4, leader: "长缆科技", amount_billion: 19.6 },
{ rank: 2, name: "半导体", count: 3, previous_count: 0, delta: 3, strength: 78, max_streak: 2, leader: "测试股份", amount_billion: 30.0 },
{ rank: 3, name: "电力", count: 1, previous_count: 8, delta: -7, strength: 67, max_streak: 3, leader: "新能股份", amount_billion: 7.3 },
];
state.dashboard.sectors = [
{ name: "电网设备", change: 4.8 },
{ name: "半导体", change: 2.1 },
{ name: "电力", change: -1.6 },
];
renderRotationHistory();
renderRotationMembers();
});
await page.locator('[data-view="rotationView"]').first().click();
await expect(page.locator("#rotationView")).toHaveClass(/redesigned-rotation-view/);
await expect(page.locator("#rotationHistoryRange")).toContainText("2026-07-14 → 2026-07-24");
await expect(page.locator("#rotationHistory .rotation-day")).toHaveCount(9);
await expect(page.locator("#rotationHistory .rotation-day").first()).toContainText("07-14");
await expect(page.locator("#rotationHistory .rotation-day").last()).toContainText("07-24");
await expect(page.locator("#rotationHistory .rotation-day").last()).toHaveClass(/latest-day/);
await expect(page.locator("#rotationView .rotation-legend")).not.toContainText("单元格 =");
const firstDayCells = page.locator("#rotationHistory .rotation-day").first().locator(".rotation-sector-chip");
await expect(firstDayCells.nth(0)).toHaveClass(/heat-strong/);
await expect(firstDayCells.nth(1)).toHaveClass(/heat-warm/);
await expect(firstDayCells.nth(2)).toHaveClass(/heat-mild/);
const cellVisuals = await firstDayCells.evaluateAll((cells) => cells.map((cell) => {
const style = getComputedStyle(cell);
return { background: style.backgroundColor, radius: parseFloat(style.borderRadius), duration: style.transitionDuration };
}));
expect(new Set(cellVisuals.map((item) => item.background)).size).toBe(3);
expect(cellVisuals.every((item) => item.radius >= 7)).toBe(true);
expect(cellVisuals.every((item) => item.duration.includes("0.28s"))).toBe(true);
await firstDayCells.nth(1).hover();
await page.waitForTimeout(300);
expect(await firstDayCells.nth(1).evaluate((cell) => getComputedStyle(cell).transform)).not.toBe("none");
const matrixFits = await page.locator("#rotationHistory").evaluate((element) => element.scrollWidth <= element.clientWidth + 1);
expect(matrixFits).toBe(true);
const longNameWrapsWithoutClipping = await page.locator("#rotationHistory .rotation-sector-chip strong", { hasText: "计算机设备与自动化设备" }).first().evaluate((element) => ({
horizontal: element.scrollWidth <= element.clientWidth + 1,
vertical: element.scrollHeight <= element.clientHeight + 1,
}));
expect(longNameWrapsWithoutClipping).toEqual({ horizontal: true, vertical: true });
await page.locator('[data-rotation-sector="电网设备"]').first().click();
await expect(page.locator("#rotationTracker")).toBeVisible();
await expect(page.locator("#rotationTracker")).toContainText("近 9 日在榜 9 天");
await expect(page.locator("#rotationDetailTitle")).toHaveText("电网设备成分股");
await expect(page.locator("#rotationDetailMeta")).toContainText("2 / 3 只");
await expect(page.locator("#rotationTableBody tr")).toHaveCount(3);
await expect(page.locator("#rotationTableBody tr").first()).toContainText("长缆科技");
await expect(page.locator("#rotationTableBody tr").last()).toContainText("当日无行情");
await page.locator("#rotationTracker .rotation-track-cancel").click();
await expect(page.locator("#rotationTracker")).toBeHidden();
await expect(page.locator("#rotationMembersEmpty")).toContainText("点击上方任意板块查看成分股");
await page.locator('[data-rotation-order="latest"]').click();
await expect(page.locator("#rotationHistory .rotation-day").first()).toContainText("07-24");
await expect(page.locator("#rotationHistoryRange")).toContainText("由近到远,左侧为最新交易日");
});
test("collection auction transfers the prototype hierarchy and keeps every dataset workflow", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.locator('[data-view="auctionView"]').first().click();
await expect(page.locator("#auctionPhaseTitle")).toHaveText("今日竞价已定格");
await page.evaluate(() => {
const base = state.auctionData.focus_rows[0];
state.auctionData.focus_rows = [
{ ...base, code: "002141", name: "高分标的", attention_score: 92, change: 6.2, amount_million: 30, volume_ratio: 4.8 },
{ ...base, code: "000001", name: "低分标的", attention_score: 68, change: -1.5, amount_million: 8, volume_ratio: 1.2, expectation: "低于预期", core_tags: [] },
];
state.auctionData.summary = { ...state.auctionData.summary, focus_count: 2, candidate_count: 2, stock_count: 5470, one_price_count: 1, amount_billion: 202.8 };
state.auctionData.themes.carry = [
{ name: "新型电力", status: "强承接", prior_limit_count: 5, leader: "立新能源", median_change: 2.36 },
{ name: "专用机械", status: "有承接", prior_limit_count: 6, leader: "长城军工", median_change: 1.82 },
{ name: "化工原料", status: "有承接", prior_limit_count: 8, leader: "金牛化工", median_change: 1.54 },
{ name: "电气设备", status: "承接弱", prior_limit_count: 33, leader: "长缆科技", median_change: 0.88 },
{ name: "运输设备", status: "分歧", prior_limit_count: 5, leader: "北自科技", median_change: 0 },
];
state.auctionData.amount_history = Array.from({ length: 10 }, (_, index) => ({
trade_date: `2026-07-${String(index + 13).padStart(2, "0")}`,
amount_billion: 150 + index * 6,
stock_count: 5000,
}));
state.auctionSortKey = "attention_score";
state.auctionSortDirection = "desc";
renderAuctionCenter();
});
await expect(page.locator("#auctionView")).toHaveClass(/redesigned-auction-view/);
await expect(page.locator("#auctionSummary > div")).toHaveCount(4);
await expect(page.locator("#auctionSummary")).toContainText("5,470 只");
const auctionTabGeometry = await page.locator(".auction-tabs-v2").evaluate((tabs) => {
const row = tabs.getBoundingClientRect();
const summary = tabs.querySelector("#auctionSummary").getBoundingClientRect();
return { rowRight: row.right, summaryRight: summary.right };
});
expect(Math.abs(auctionTabGeometry.rowRight - auctionTabGeometry.summaryRight)).toBeLessThanOrEqual(1);
const datasetLabels = await page.locator("[data-auction-dataset]").allTextContents();
expect(datasetLabels.map((label) => label.replace(/\s+/g, " ").trim())).toEqual(["重点异动 2", "我的自选 1", "全部候选 2", "竞价一字 1"]);
const workspaceColumns = await page.locator(".auction-workspace-v2").evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").filter(Boolean).length);
expect(workspaceColumns).toBe(2);
await expect(page.locator(".auction-side-v2 .auction-side-card")).toHaveCount(2);
await expect(page.locator(".auction-news-entry")).toHaveCount(0);
await expect(page.locator("#auctionThemeCarry .auction-theme-row")).toHaveCount(5);
await expect(page.locator("#auctionAmountTrend .auction-amount-day")).toHaveCount(10);
await expect(page.locator("#auctionTableHead th")).toHaveCount(8);
await expect(page.locator("#auctionTableHead th[data-auction-sort]")).toHaveCount(4);
await expect(page.locator("#auctionTableBody tr").first()).toContainText("高分标的");
await page.locator('#auctionTableHead th[data-auction-sort="attention_score"]').click();
await expect(page.locator("#auctionTableBody tr").first()).toContainText("低分标的");
await page.locator('[data-auction-dataset="onePrice"]').click();
await expect(page.locator("#auctionTableHead th")).toHaveCount(6);
await expect(page.locator("#auctionExpectationControls")).toBeHidden();
await expect(page.locator("#auctionSearch")).toBeVisible();
await expect(page.locator("#auctionExportButton")).toBeVisible();
const download = page.waitForEvent("download");
await page.locator("#auctionExportButton").click();
await download;
await page.locator("#auctionTableBody tr").first().click();
await expect(page.locator("#stockDialog")).toBeVisible();
await page.locator("#closeStockDialog").click();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator(".auction-summary-v2 > div")).toHaveCount(4);
await expect(page.locator(".auction-side-v2 .auction-side-card")).toHaveCount(2);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
});
test("auction, themes and popularity reuse stock detail interactions", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="auctionView"]').first().click();
await expect(page.locator("#auctionPhaseTitle")).toHaveText("今日竞价已定格");
await expect(page.locator("#auctionRefreshButton")).toBeHidden();
await expect(page.locator("#auctionTableBody tr")).toHaveCount(1);
await expect(page.locator("#auctionTableBody")).toContainText("人气前5");
await page.locator('[data-auction-filter="above"]').click();
await expect(page.locator("#auctionTableBody tr")).toHaveCount(1);
await expect(page.locator('[data-auction-filter="above"]')).toHaveClass(/active/);
await expect(page.locator("#auctionThemeCarry")).toContainText("强承接");
await expect(page.locator("#auctionAmountValue")).toHaveText("2.50 亿");
await expect(page.locator("#auctionNewsTitle")).toHaveCount(0);
await expect(page.locator(".auction-news-entry")).toHaveCount(0);
await page.locator('[data-auction-dataset="onePrice"]').click();
await expect(page.locator("#auctionTableBody tr")).toHaveCount(1);
await expect(page.locator("#auctionTableBody")).toContainText("三板以上");
await expect(page.locator("#auctionExpectationControls")).toBeHidden();
await expect(page.locator("#auctionSearch")).toBeVisible();
await page.locator('[data-auction-dataset="watchlist"]').click();
await expect(page.locator("#auctionTableBody tr")).toHaveCount(1);
await expect(page.locator("#auctionTableBody")).toContainText("Watch Stock");
await page.locator('[data-auction-dataset="focus"]').click();
await page.evaluate(() => {
state.auctionData.meta = { phase: "selection", available: false, actionable: true };
state.auctionData.rows = [];
state.auctionData.focus_rows = [];
renderAuctionCenter();
});
await expect(page.locator("#auctionPhaseTitle")).toHaveText("等待最终竞价");
await expect(page.locator("#auctionRefreshButton")).toBeVisible();
await expect(page.locator("#auctionEmpty")).toContainText("正在等待 9:25 最终竞价数据");
await page.locator('[data-view="themeLibraryView"]').first().click();
await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(1);
await expect(page.locator("#themeMemberTableBody tr")).toHaveCount(1);
await expect(page.locator("#themeDetailName")).toHaveText("人工智能");
await page.locator('[data-view="popularityView"]').first().click();
await expect(page.locator("#popularityTableBody tr")).toHaveCount(1);
await expect(page.locator("#popularitySummary")).toContainText("双榜共识");
await expect(page.locator("#popularityTableBody")).not.toContainText("双榜共识");
await page.locator("#popularityTableBody tr").click();
await expect(page.locator("#stockDialog")).toBeVisible();
});
test("theme library preserves the full master-detail workflow in its redesigned layout", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="themeLibraryView"]').first().click();
await expect(page.locator("#themeLibraryView")).toHaveClass(/redesigned-theme-view/);
await expect(page.locator(".theme-summary-v2 > div")).toHaveCount(4);
await expect(page.locator(".theme-directory-card-v2")).toBeVisible();
await expect(page.locator(".theme-market-card-v2")).toBeVisible();
await expect(page.locator(".theme-members-card-v2")).toBeVisible();
await expect(page.locator("#themeDetailName")).toHaveText("人工智能");
await expect(page.locator("#themeDetailMetrics > div")).toHaveCount(5);
await expect(page.locator("#themeMemberCount")).toHaveText("有行情 1 / 1");
await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveAttribute("aria-pressed", "true");
await expect(page.locator("#themeDetailChart")).toHaveCount(0);
const themePreviewRequest = page.waitForRequest((request) => request.url().includes("/api/search/detail?") && request.url().includes("type=theme"));
await page.locator(".market-preview-trigger").hover();
await themePreviewRequest;
await expect(page.locator("#stockPreview")).toBeVisible();
await expect(page.locator("#stockPreviewName")).toHaveText("人工智能");
await expect(page.locator("#stockPreviewSource")).toHaveText("日 K 行情 · 2 个交易日");
const intradayRequest = page.waitForRequest((request) => request.url().includes("/api/chart/intraday?") && request.url().includes("type=theme"));
await page.locator('[data-preview-chart="intraday"]').click();
const requestedIntraday = new URL((await intradayRequest).url());
expect(requestedIntraday.searchParams.get("id")).toBe("885728.TI");
await expect(page.locator("#stockPreviewSource")).toHaveText("最新分时 · 1分钟");
await page.locator("#closeStockPreview").click();
await page.locator("#themeSearch").fill("不存在的题材");
await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(0);
await expect(page.locator("#themeDirectory")).toContainText("没有匹配的题材");
await page.locator("#themeSearch").fill("人工智能");
await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(1);
await page.locator("#themeMemberTableBody tr").click();
await expect(page.locator("#stockDialog")).toBeVisible();
await page.locator("#closeStockDialog").click();
await page.evaluate(() => {
const directory = document.querySelector("#themeDirectory");
const directorySeed = directory.querySelector("button");
const members = document.querySelector("#themeMemberTableBody");
const memberSeed = members.querySelector("tr");
for (let index = 0; index < 24; index += 1) directory.appendChild(directorySeed.cloneNode(true));
for (let index = 0; index < 20; index += 1) members.appendChild(memberSeed.cloneNode(true));
});
const desktop = await page.evaluate(() => ({
columns: getComputedStyle(document.querySelector(".theme-library-workspace-v2")).gridTemplateColumns.split(" ").filter(Boolean).length,
mainOverflows: document.querySelector(".app-main").scrollHeight > document.querySelector(".app-main").clientHeight + 1,
directoryOverflows: document.querySelector("#themeDirectory").scrollHeight > document.querySelector("#themeDirectory").clientHeight + 1,
membersOverflow: document.querySelector(".theme-members-frame-v2").scrollHeight > document.querySelector(".theme-members-frame-v2").clientHeight + 1,
}));
expect(desktop).toEqual({ columns: 2, mainOverflows: false, directoryOverflows: true, membersOverflow: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator(".theme-head-actions-v2")).toBeVisible();
const mobile = await page.evaluate(() => ({
columns: getComputedStyle(document.querySelector(".theme-library-workspace-v2")).gridTemplateColumns.split(" ").filter(Boolean).length,
pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1,
memberTableScrolls: document.querySelector(".theme-members-frame-v2").scrollWidth > document.querySelector(".theme-members-frame-v2").clientWidth + 1,
}));
expect(mobile).toEqual({ columns: 1, pageFits: true, memberTableScrolls: true });
});
test("popularity ranking transfers the three-glance hierarchy and dynamic source tables", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="popularityView"]').first().click();
await expect(page.locator("#popularityView")).toHaveClass(/redesigned-popularity-view/);
await expect(page.locator("#popularitySummary article")).toHaveCount(3);
await expect(page.locator("#popularitySummary article").nth(0)).toContainText("同花顺热度 Top3");
await expect(page.locator("#popularitySummary article").nth(1)).toContainText("东方财富热度 Top3");
await expect(page.locator("#popularitySummary article").nth(2)).toContainText("双榜共识");
const glanceStyles = await page.locator("#popularitySummary").evaluate((summary) => ({
background: getComputedStyle(summary).backgroundColor,
shadow: getComputedStyle(summary).boxShadow,
borderWidth: getComputedStyle(summary).borderWidth,
cardBackgrounds: [...summary.children].map((card) => getComputedStyle(card).backgroundColor),
cardShadows: [...summary.children].map((card) => getComputedStyle(card).boxShadow),
}));
expect(new Set(glanceStyles.cardBackgrounds).size).toBe(1);
expect(glanceStyles.cardBackgrounds[0]).not.toBe(glanceStyles.background);
expect(glanceStyles.shadow).toBe("none");
expect(glanceStyles.borderWidth).toBe("0px");
expect(glanceStyles.cardShadows).toEqual(["none", "none", "none"]);
await expect(page.locator("#popularityTableTitle")).toHaveText("双榜综合榜");
await expect(page.locator("#popularityTableHead th")).toHaveCount(8);
await expect(page.locator("#popularityTableHead")).not.toContainText("榜单状态");
await expect(page.locator("#popularityTableBody .popularity-source-tag-v2")).toHaveCount(0);
await page.locator('[data-popularity-source="ths"]').click();
await expect(page.locator("#popularityTableTitle")).toHaveText("同花顺榜");
await expect(page.locator("#popularityTableHead")).toContainText("榜单状态");
await expect(page.locator("#popularityTableHead")).not.toContainText("东方财富");
await expect(page.locator("#popularityTableBody .popularity-source-tag-v2")).toHaveText("双榜共识");
await page.locator('[data-popularity-source="dc"]').click();
await expect(page.locator("#popularityTableTitle")).toHaveText("东方财富榜");
await expect(page.locator("#popularityTableHead")).not.toContainText("同花顺");
await page.locator("#popularitySearch").fill("不存在的股票");
await expect(page.locator("#popularityEmpty")).toBeVisible();
await page.locator("#popularitySearch").fill("Test Stock");
await expect(page.locator("#popularityTableBody tr")).toHaveCount(1);
await page.locator("#popularityTableBody tr").click();
await expect(page.locator("#stockDialog")).toBeVisible();
await page.locator("#closeStockDialog").click();
await page.locator('[data-popularity-source="combined"]').click();
await page.evaluate(() => {
const body = document.querySelector("#popularityTableBody");
const seed = body.querySelector("tr");
for (let index = 0; index < 30; index += 1) body.appendChild(seed.cloneNode(true));
});
const desktop = await page.evaluate(() => ({
mainOverflows: document.querySelector(".app-main").scrollHeight > document.querySelector(".app-main").clientHeight + 1,
tableOverflows: document.querySelector(".popularity-table-frame-v2").scrollHeight > document.querySelector(".popularity-table-frame-v2").clientHeight + 1,
}));
expect(desktop).toEqual({ mainOverflows: false, tableOverflows: true });
await page.setViewportSize({ width: 390, height: 844 });
const mobile = await page.evaluate(() => ({
pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1,
tableScrolls: document.querySelector(".popularity-table-frame-v2").scrollWidth > document.querySelector(".popularity-table-frame-v2").clientWidth + 1,
}));
expect(mobile).toEqual({ pageFits: true, tableScrolls: true });
});
test("dragon-tiger redesign keeps the merged empty state and independent card hit zones", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.locator('[data-view="dragonView"]').first().click();
await expect(page.locator("#dragonView")).toHaveClass(/redesigned-dragon-view/);
await expect(page.locator("#dragonEmptyState")).toBeVisible();
await expect(page.locator("#dragonEmptyTitle")).toContainText("2026-07-22");
await expect(page.locator("#dragonDailyContent")).toBeHidden();
await expect(page.locator("#dragonPreviousButton")).toBeVisible();
await expect(page.locator("#dragonEmptyRefreshButton")).toBeVisible();
await page.evaluate(() => {
const operation = (code, name, net) => ({
code, name, change: net > 0 ? 6.8 : -2.4, buy_million: net > 0 ? 42 : 5,
sell_million: net > 0 ? 7 : 31, net_buy_million: net,
direction: net > 0 ? "买入" : "卖出", seat_name: "测试营业部", tag: "题材核心", reason: "日涨幅偏离值达标",
});
const trader = (id, name, net, code) => ({
id, name, identity_type: "trader", recognized: true, description: "聚焦市场核心,顺势参与强势方向",
stock_count: 1, operation_count: 1, buy_million: net > 0 ? 42 : 5,
sell_million: net > 0 ? 7 : 31, net_buy_million: net,
operations: [operation(code, `${name}标的`, net)],
});
state.dragonTiger = {
meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "success" },
summary: { trader_count: 3, operation_count: 3, seat_net_buy_million: 34, active_stock_count: 3 },
traders: [trader("alpha", "甲游资", 35, "002141"), trader("beta", "乙游资", -26, "000001"), trader("gamma", "丙游资", 25, "000002")],
unclassified_seats: [],
};
renderDragonTiger();
});
await expect(page.locator("#dragonEmptyState")).toBeHidden();
await expect(page.locator("#dragonDailyContent")).toBeVisible();
await expect(page.locator("#dragonSummary .dragon-metric")).toHaveCount(4);
await expect(page.locator("#dragonTraderList .dragon-trader-card")).toHaveCount(3);
await expect(page.locator("#dragonTraderList .dragon-card-hit-zone")).toHaveCount(3);
await expect(page.locator("#dragonTraderDetail")).toBeVisible();
await expect(page.locator("#dragonTraderDetail")).toContainText("甲游资");
expect((await page.locator("#dragonTraderDetail .dragon-operation-table thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([
"序号", "股票", "方向", "涨幅(%", "买入(百万)", "卖出(百万)", "净额(百万)", "关联席位", "标签 / 上榜原因",
]);
const operationCells = page.locator("#dragonTraderDetail .dragon-operation-table tbody tr").first().locator("td");
await expect(operationCells.nth(0)).toHaveText("1");
await expect(operationCells.nth(1)).toContainText("002141");
await expect(operationCells.nth(1)).toContainText("甲游资标的");
const hitZones = await page.locator("#dragonTraderList .dragon-card-hit-zone").evaluateAll((zones) => zones.map((zone) => {
const box = zone.getBoundingClientRect();
return { left: box.left, right: box.right, width: box.width };
}));
expect(hitZones.every((zone) => zone.width >= 18)).toBe(true);
expect(hitZones.slice(1).every((zone, index) => zone.left >= hitZones[index].right - 1)).toBe(true);
await page.locator('[data-dragon-trader="gamma"]').hover();
await expect(page.locator('[data-dragon-card="gamma"]')).toHaveClass(/hovered/);
await page.locator('[data-dragon-trader="beta"]').click();
await expect(page.locator('[data-dragon-trader="beta"]')).toHaveAttribute("aria-pressed", "true");
await expect(page.locator("#dragonTraderDetail")).toContainText("乙游资");
await page.locator('[data-dragon-filter="buy"]').click();
await expect(page.locator("#dragonTraderList .dragon-trader-card")).toHaveCount(2);
await page.locator('[data-dragon-filter="sell"]').click();
await expect(page.locator("#dragonTraderList .dragon-trader-card")).toHaveCount(1);
await page.locator("#dragonSearch").fill("不存在");
await expect(page.locator("#dragonTraderList .dragon-empty")).toBeVisible();
await expect(page.locator("#dragonTraderDetail")).toBeHidden();
await page.locator("#dragonSearch").fill("");
await page.locator('[data-dragon-filter="all"]').click();
await page.locator("#dragonTraderDetail tbody tr").first().click();
await expect(page.locator("#stockDialog")).toBeVisible();
await page.locator("#closeStockDialog").click();
await page.setViewportSize({ width: 1366, height: 768 });
const scrollOwnership = await page.evaluate(() => {
const body = document.querySelector("#dragonTraderDetail tbody");
const seed = body.querySelector("tr");
for (let index = 0; index < 20; index += 1) body.appendChild(seed.cloneNode(true));
const main = document.querySelector(".app-main");
const daily = document.querySelector("#dragonDailyContent");
const operations = document.querySelector("#dragonTraderDetail .trader-operations");
return {
mainOverflow: getComputedStyle(main).overflowY,
pageScrolls: main.scrollHeight > main.clientHeight,
dailyOverflow: getComputedStyle(daily).overflowY,
dailyFits: daily.scrollHeight <= daily.clientHeight + 1,
operationOverflow: getComputedStyle(operations).overflowY,
operationsFit: operations.scrollHeight <= operations.clientHeight + 1,
descriptionSize: parseFloat(getComputedStyle(document.querySelector("#dragonTraderDetail .dragon-detail-header p")).fontSize),
};
});
expect(scrollOwnership).toEqual({
mainOverflow: "auto",
pageScrolls: true,
dailyOverflow: "visible",
dailyFits: true,
operationOverflow: "auto",
operationsFit: true,
descriptionSize: 13,
});
await page.locator("#dragonProfilesButton").click();
await expect(page.locator("#dragonProfilesContent")).toBeVisible();
await expect(page.locator("#dragonDailyContent")).toBeHidden();
await expect(page.locator("#hotMoneyProfileSummary > span")).toHaveCount(3);
await expect(page.locator("#hotMoneyProfileList .hot-money-profile-row-v2")).toHaveCount(3);
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("赵老哥");
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("华泰证券浙江分公司");
await page.locator("#hotMoneyProfileSearch").fill("宛平南路");
await expect(page.locator("#hotMoneyProfileList .hot-money-profile-row-v2")).toHaveCount(1);
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("炒股养家");
await page.locator("#hotMoneyProfileSearch").fill("");
await page.locator('[data-hot-money-profile="hot-money-profile-3"]').click();
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("名录暂未收录该游资的公开简介");
await page.setViewportSize({ width: 390, height: 844 });
const profileMobile = await page.evaluate(() => {
const list = document.querySelector("#hotMoneyProfileList").getBoundingClientRect();
const detail = document.querySelector("#hotMoneyProfileDetail").getBoundingClientRect();
return {
pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1,
detailBelowList: detail.top >= list.bottom - 1,
};
});
expect(profileMobile).toEqual({ pageFits: true, detailBelowList: true });
await page.locator("#dragonDailyButton").click();
const mobile = await page.evaluate(() => ({
pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1,
operationsScroll: document.querySelector("#dragonTraderDetail .trader-operations").scrollWidth > document.querySelector("#dragonTraderDetail .trader-operations").clientWidth + 1,
}));
expect(mobile).toEqual({ pageFits: true, operationsScroll: true });
});
test("auction owns a single vertical scroll container across desktop densities", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.locator('[data-view="auctionView"]').first().click();
await page.evaluate(() => {
const body = document.querySelector("#auctionTableBody");
const seed = body.querySelector("tr");
if (!seed) return;
for (let index = 0; index < 28; index += 1) body.appendChild(seed.cloneNode(true));
});
const measure = () => page.evaluate(() => {
const box = (selector) => {
const element = document.querySelector(selector);
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return {
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
overflowY: style.overflowY,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
};
};
return {
viewportHeight: innerHeight,
html: box("html"),
body: box("body"),
main: box(".app-main"),
view: box("#auctionView"),
primary: box(".auction-primary-card"),
frame: box(".auction-table-frame-v2"),
side: box(".auction-side-v2"),
};
});
for (const viewport of [{ width: 1920, height: 1080 }, { width: 1536, height: 864 }]) {
await page.setViewportSize(viewport);
const layout = await measure();
expect(layout.html.scrollHeight).toBeLessThanOrEqual(layout.viewportHeight);
expect(layout.body.scrollHeight).toBeLessThanOrEqual(layout.viewportHeight);
expect(layout.main.overflowY).toBe("hidden");
expect(layout.frame.overflowY).toBe("auto");
expect(layout.frame.scrollHeight).toBeGreaterThan(layout.frame.clientHeight);
expect(layout.side.top).toBeLessThan(layout.primary.bottom);
}
await page.setViewportSize({ width: 1280, height: 720 });
const compact = await measure();
expect(compact.html.scrollHeight).toBeLessThanOrEqual(compact.viewportHeight);
expect(compact.main.overflowY).toBe("hidden");
expect(compact.frame.overflowY).toBe("auto");
expect(compact.frame.scrollHeight).toBeGreaterThan(compact.frame.clientHeight);
expect(compact.side.top).toBeLessThan(compact.primary.bottom);
await page.setViewportSize({ width: 3840, height: 2160 });
const wide = await measure();
expect(wide.html.scrollHeight).toBeLessThanOrEqual(wide.viewportHeight);
expect(wide.view.width).toBeLessThanOrEqual(wide.main.clientWidth + 1);
expect(wide.view.scrollWidth).toBeLessThanOrEqual(wide.view.clientWidth + 1);
});
test("regular account cannot see admin controls and member features are gated", async ({ page }) => {
await mockApplication(page, session("user", false));
await page.goto("/index.html");
await expect(page.locator("#settingsButton")).toBeHidden();
await expect(page.locator("#syncButton")).toBeHidden();
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
await page.locator('[data-view="screenerView"]').first().click();
await expect(page.locator("#screenerView .member-gate")).toBeVisible();
await expect(page.locator('[data-screener-mode="quant"]')).toBeDisabled();
await expect(page.locator("#quantRunButton")).toBeDisabled();
await page.locator("#assistantButton").click();
await expect(page.locator("#settingsDialog")).toBeHidden();
await expect(page.locator("#assistantDialog")).toBeVisible();
await expect(page.locator("#assistantMemberGate")).toContainText("复盘助手仅对会员开放");
await expect(page.locator("#assistantMemberGate")).toBeVisible();
await expect(page.locator("#assistantMemberContent")).toHaveAttribute("aria-disabled", "true");
await expect(page.locator("#assistantQuestion")).toBeDisabled();
await expect(page.locator("[data-assistant-prompt]").first()).toBeDisabled();
await expect(page.locator("#sendAssistant")).toBeDisabled();
});
test("stock hover preview ignores the selected historical date", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator("#tradeDate").fill("2026-07-01");
const requestPromise = page.waitForRequest((request) => request.url().includes("/api/stock/002141/preview"));
await page.evaluate(() => showStockPreview("002141", document.querySelector("#globalSearchButton")));
const request = await requestPromise;
const requestUrl = new URL(request.url());
expect(requestUrl.searchParams.has("trade_date")).toBe(false);
await expect(page.locator("#stockPreviewDate")).toHaveText("2026-07-23");
await expect(page.locator("#stockPreviewName")).toHaveText("Test Stock");
await expect(page.locator("#stockPreviewSource")).toHaveText("日 K 行情 · 2 个交易日");
await expect(page.locator('[data-preview-chart="daily"]')).toHaveClass(/active/);
await page.locator('[data-preview-chart="intraday"]').click();
await expect(page.locator("#stockPreviewDate")).toHaveText("2026-07-24");
await expect(page.locator("#stockPreviewSource")).toHaveText("最新分时 · 1分钟");
const canvasColors = await page.locator("#stockPreviewChart").evaluate((canvas) => {
const pixels = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height).data;
const colors = new Set();
for (let index = 0; index < pixels.length; index += 16) {
if (pixels[index + 3]) colors.add(`${pixels[index]},${pixels[index + 1]},${pixels[index + 2]}`);
}
return colors.size;
});
expect(canvasColors).toBeGreaterThan(4);
});
test("stock hover preview loading state follows the dark chart theme", async ({ page }) => {
await mockApplication(page, session("user", true), { previewDelay: 500 });
await page.goto("/index.html");
await page.evaluate(() => {
document.documentElement.dataset.theme = "dark";
showStockPreview("002141", document.querySelector("#globalSearchButton"));
});
const loading = page.locator("#stockPreviewLoading");
await expect(loading).toBeVisible();
await expect(page.locator('[data-preview-chart="daily"]')).toHaveClass(/active/);
const colors = await page.evaluate(() => ({
overlay: getComputedStyle(document.querySelector("#stockPreviewLoading")).backgroundColor,
chart: getComputedStyle(document.documentElement).getPropertyValue("--chart-background").trim(),
pixel: Array.from(
document.querySelector("#stockPreviewChart").getContext("2d").getImageData(10, 10, 1, 1).data,
),
}));
expect(colors.overlay).not.toBe("rgb(255, 255, 255)");
expect(colors.chart).toBe("#181b1e");
expect(colors.pixel.slice(0, 3)).toEqual([24, 27, 30]);
});
test("rising candle body stays hollow and its wick stops at both edges", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
const pixels = await page.evaluate(() => {
const canvas = document.createElement("canvas");
canvas.width = 40;
canvas.height = 80;
const context = canvas.getContext("2d");
context.fillStyle = currentChartPalette().background;
context.fillRect(0, 0, 40, 80);
const priceY = (value) => 90 - value * 8;
drawCandlestick(context, 20, { high: 10, close: 8, open: 6, low: 4 }, priceY, 10);
const read = (x, y) => Array.from(context.getImageData(x, y, 1, 1).data);
const reddest = (left, top, width, height) => {
const data = context.getImageData(left, top, width, height).data;
let selected = [0, 0, 0, 0];
for (let index = 0; index < data.length; index += 4) {
const pixel = [data[index], data[index + 1], data[index + 2], data[index + 3]];
if (pixel[0] - pixel[1] > selected[0] - selected[1]) selected = pixel;
}
return selected;
};
return {
upperWick: reddest(19, 10, 3, 16),
bodyCenter: read(20, 34),
lowerWick: reddest(19, 43, 3, 17),
bodyBorder: reddest(14, 26, 3, 17),
};
});
for (const redPixel of [pixels.upperWick, pixels.lowerWick, pixels.bodyBorder]) {
expect(redPixel[0] - redPixel[1]).toBeGreaterThan(40);
expect(redPixel[0] - redPixel[2]).toBeGreaterThan(40);
}
expect(pixels.bodyCenter.slice(0, 3)).toEqual([251, 252, 253]);
});
test("stock and market detail dialogs switch from daily K to intraday", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.evaluate(() => openStock("002141", { code: "002141", name: "Test Stock", sector: "Test Sector" }));
await page.locator('[data-stock-detail-chart="intraday"]').click();
await expect(page.locator("#chartSource")).toHaveText("分时 · 2026-07-24");
await expect(page.locator('[data-stock-detail-chart="intraday"]')).toHaveAttribute("aria-pressed", "true");
await page.locator('[data-stock-detail-chart="daily"]').click();
await expect(page.locator("#chartSource")).toContainText("日 K 行情");
await page.locator("#closeStockDialog").click();
await page.evaluate(() => openEntityDetail({ id: "000001.SH", code: "000001.SH", name: "上证指数", type: "index", type_label: "指数" }));
await page.locator('[data-entity-detail-chart="intraday"]').click();
await expect(page.locator("#entityDetailDate")).toHaveText("分时 · 2026-07-24");
await expect(page.locator('[data-entity-detail-chart="intraday"]')).toHaveAttribute("aria-pressed", "true");
const colors = await page.locator("#entityDetailChart").evaluate((canvas) => {
const pixels = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height).data;
return new Set(Array.from({ length: Math.floor(pixels.length / 16) }, (_, index) => {
const offset = index * 16;
return `${pixels[offset]},${pixels[offset + 1]},${pixels[offset + 2]},${pixels[offset + 3]}`;
})).size;
});
expect(colors).toBeGreaterThan(4);
const offsets = await page.evaluate(() => [
intradayMinuteOffset("09:30"),
intradayMinuteOffset("11:30"),
intradayMinuteOffset("13:00"),
intradayMinuteOffset("15:00"),
]);
expect(offsets).toEqual([0, 120, 120, 240]);
});
test("saved daily fortune opens in the reading dialog without regenerating", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="heavenView"]').first().click();
await page.locator('[data-heaven-panel="fortune"]').click();
await page.evaluate(() => {
state.heavenSetup = { field: {}, chart: { available: true } };
state.heavenInterpretations.fortune = {
id: 31,
mode: "fortune",
context_date: "20260723",
subject: "2026-07-23 观气",
subject_detail: "丙午年 · 乙未月 · 己丑日 · 土气偏显",
answer: Array.from({ length: 36 }, (_, index) => `第${index + 1}节:三层气机已经合参,今日宜先定节奏,再看行动。`).join("\n\n"),
created_at: "2026-07-23T09:12:00+08:00",
};
updateHeavenInterpretationControls();
});
await expect(page.locator("#interpretFortuneButton")).toHaveText("已解运");
const interpretRequests = [];
page.on("request", (request) => {
if (request.url().includes("/api/heaven/interpret")) interpretRequests.push(request.url());
});
await page.locator("#interpretFortuneButton").click();
await expect(page.locator("#heavenReadingDialog")).toBeVisible();
await expect(page.locator("#heavenReadingAnswer")).toContainText("今日宜先定节奏");
const readingScroll = await page.locator("#heavenReadingCurrent").evaluate((element) => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
overflowY: getComputedStyle(element).overflowY,
}));
expect(readingScroll.scrollHeight).toBeGreaterThan(readingScroll.clientHeight);
expect(readingScroll.overflowY).toBe("auto");
await page.locator("#heavenReadingCurrent").evaluate((element) => { element.scrollTop = element.scrollHeight; });
expect(await page.locator("#heavenReadingCurrent").evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
expect(interpretRequests).toHaveLength(0);
await page.locator('[data-heaven-reading-tab="history"]').click();
await expect(page.locator("#heavenReadingHistoryList .heaven-reading-history-item")).toHaveCount(1);
});
test("heaven reading loading uses the matching canvas scene and stops cleanly", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
const cases = [
["trend", "hexagram"],
["fortune", "fortune"],
["heart", "hexagram"],
];
for (const [mode, scene] of cases) {
await page.evaluate(([readingMode]) => openHeavenReading(readingMode, { loading: true }), [mode]);
const canvas = page.locator("#heavenReadingCanvas");
await expect(canvas).toBeVisible();
await expect(canvas).toHaveAttribute("data-scene", scene);
await expect(canvas).toHaveAttribute("data-running", "true");
await expect(canvas).toHaveAttribute("data-looping", "true");
await page.waitForTimeout(180);
const pixels = await canvas.evaluate((element) => {
const context = element.getContext("2d");
const data = context.getImageData(0, 0, element.width, element.height).data;
const colors = new Set();
const step = Math.max(4, Math.floor(data.length / 1200 / 4) * 4);
for (let index = 0; index < data.length; index += step) {
colors.add(`${data[index]},${data[index + 1]},${data[index + 2]},${data[index + 3]}`);
}
return { width: element.width, height: element.height, colors: colors.size };
});
expect(pixels.width).toBeGreaterThan(300);
expect(pixels.height).toBeGreaterThan(300);
expect(pixels.colors).toBeGreaterThan(3);
await page.evaluate(() => { heavenReadingAnimation.startedAt = performance.now() - 13_100; });
await expect(canvas).toHaveAttribute("data-cycle", "1");
await expect(canvas).toHaveAttribute("data-running", "true");
if (mode === "trend") {
const completionMs = await page.evaluate(async () => {
const started = performance.now();
await heavenReadingAnimation.complete();
return performance.now() - started;
});
expect(completionMs).toBeGreaterThanOrEqual(1600);
expect(completionMs).toBeLessThan(2000);
await expect(canvas).toHaveAttribute("data-running", "false");
}
await page.locator("#closeHeavenReadingDialog").click();
await expect(canvas).toHaveAttribute("data-running", "false");
await expect(canvas).toHaveAttribute("data-looping", "false");
}
});
test("heart breathing prepares once then contracts on each exhale", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="heavenView"]').first().click();
await page.locator('[data-heaven-panel="heart"]').click();
await page.locator('[data-heart-question-preset="unthemed"]').click();
await page.evaluate(() => startHeartBreathing());
const timing = await page.evaluate(() => ({
remaining: state.heartBreathingEndsAt - Date.now(),
incenseNames: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationName,
incenseDurations: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration,
incenseDelays: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDelay,
rippleAnimation: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationName,
}));
expect(timing.remaining).toBeGreaterThan(45_000);
expect(timing.remaining).toBeLessThanOrEqual(46_000);
expect(timing.incenseNames).toContain("heart-incense-burn");
expect(timing.incenseNames).toContain("heart-incense-glow");
expect(timing.incenseDurations).toContain("45s");
expect(timing.incenseDelays).toContain("1s");
expect(timing.rippleAnimation).toBe("none");
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "prepare");
await expect(page.locator("#breathingPhase")).toHaveText("静");
await expect(page.locator("#breathingSeconds, #breathingProgress, .breathing-orbit")).toHaveCount(0);
await page.evaluate(() => {
clearInterval(state.heartTimer);
state.heartTimer = null;
state.heartSeconds = 45;
state.heartBreathingEndsAt = Date.now() + 44_500;
updateBreathingDisplay();
});
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "inhale");
await expect(page.locator("#breathingPhase")).toHaveText("吸");
await expect(page.locator(".heart-breath-ripple span").first()).toHaveCSS("transition-duration", "3s");
await expect(page.locator(".heart-breath-ripple span").first()).toHaveCSS("transform", /matrix\(1, 0, 0, 1,/);
await page.evaluate(() => {
state.heartSeconds = 42;
state.heartBreathingEndsAt = Date.now() + 41_500;
updateBreathingDisplay();
});
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "hold");
await expect(page.locator("#breathingPhase")).toHaveText("顿");
await page.evaluate(() => {
state.heartSeconds = 39;
state.heartBreathingEndsAt = Date.now() + 38_500;
updateBreathingDisplay();
});
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "exhale");
await expect(page.locator("#breathingPhase")).toHaveText("呼");
await expect(page.locator(".heart-breath-ripple span").first()).toHaveCSS("transition-duration", "4s");
});
test("heart question presets stay editable and lock only during the ritual", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="heavenView"]').first().click();
await page.locator('[data-heaven-panel="heart"]').click();
const input = page.locator("#heartQuestionInput");
const start = page.locator("#startBreathingButton");
const trade = page.locator('[data-heart-question-preset="trade"]');
const mind = page.locator('[data-heart-question-preset="mind"]');
const unthemed = page.locator('[data-heart-question-preset="unthemed"]');
await expect(input).toHaveValue("");
await expect(start).toBeDisabled();
await expect(trade).toHaveAttribute("aria-pressed", "false");
await expect(mind).toHaveAttribute("aria-pressed", "false");
await expect(unthemed).toHaveAttribute("aria-pressed", "false");
await trade.click();
await expect(input).toHaveValue("关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?");
await expect(trade).toHaveAttribute("aria-pressed", "true");
await mind.click();
await expect(input).toHaveValue("此刻影响我交易判断的情绪、执念或盲点是什么?");
await expect(mind).toHaveAttribute("aria-pressed", "true");
await unthemed.click();
await expect(input).toHaveValue("不设具体问题,只观此刻一念。");
await expect(unthemed).toHaveAttribute("aria-pressed", "true");
await input.fill("我是否因为害怕错过而忽略了这笔交易的退出条件?");
await expect(start).toBeEnabled();
await expect(trade).toHaveAttribute("aria-pressed", "false");
await expect(mind).toHaveAttribute("aria-pressed", "false");
await expect(unthemed).toHaveAttribute("aria-pressed", "false");
expect(await page.evaluate(() => ({
question: state.heartQuestion,
preset: state.heartQuestionPreset,
}))).toEqual({
question: "我是否因为害怕错过而忽略了这笔交易的退出条件?",
preset: "custom",
});
await start.click();
await expect(page.locator("#heartBreathing")).toHaveClass(/active-heart-stage/);
await expect(input).toBeDisabled();
await expect(trade).toBeDisabled();
await page.locator("#heartBreathing [data-heart-return]").click();
await expect(page.locator("#heartIntro")).toHaveClass(/active-heart-stage/);
await expect(input).toBeEnabled();
await expect(input).toHaveValue("我是否因为害怕错过而忽略了这笔交易的退出条件?");
await expect(trade).toBeEnabled();
});
test("stylesheet layers do not repeat identical rules in the same cascade context", async ({ page }) => {
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
const duplicates = await page.evaluate(() => {
const occurrences = new Map();
const visitRules = (rules, href, context = []) => {
for (const rule of Array.from(rules || [])) {
if (typeof rule.selectorText === "string" && rule.style) {
const key = `${context.join("\u0001")}\u0000${rule.selectorText}\u0000${rule.style.cssText}`;
const rows = occurrences.get(key) || [];
rows.push(href);
occurrences.set(key, rows);
continue;
}
if (!rule.cssRules) continue;
const condition = rule.conditionText || rule.media?.mediaText || rule.name || "";
visitRules(rule.cssRules, href, [...context, `${rule.type}:${condition}`]);
}
};
for (const sheet of Array.from(document.styleSheets)) {
const href = sheet.href ? new URL(sheet.href).pathname : "inline";
visitRules(sheet.cssRules, href);
}
return Array.from(occurrences.entries())
.filter(([, paths]) => paths.length > 1)
.map(([rule, paths]) => ({ rule, paths }));
});
expect(duplicates).toEqual([]);
});
test("mobile shell stays within the viewport", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await mockApplication(page, session("user", true));
await page.goto("/index.html?ui=desktop");
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator("#globalSearchButton")).toBeHidden();
await expect(page.locator("#headerMenuButton")).toBeVisible();
await page.locator("#headerMenuButton").click();
await expect(page.locator("#headerCommandGroup")).toBeVisible();
await expect(page.locator('[data-mobile-command-target="globalSearchButton"]')).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator("#headerCommandGroup")).toBeHidden();
await expect(page.locator(".module-nav .mobile-primary-tab:visible")).toHaveCount(5);
await expect(page.locator('[data-view="limitPool"]')).toHaveClass(/mobile-active/);
for (const entry of await page.locator(".module-nav .mobile-primary-tab:visible").all()) {
const box = await entry.boundingBox();
expect(box.height).toBeGreaterThanOrEqual(44);
}
const mobileShell = await page.evaluate(() => {
const header = document.querySelector(".app-header").getBoundingClientRect();
const main = document.querySelector(".app-main").getBoundingClientRect();
return { headerBottom: header.bottom, mainTop: main.top };
});
expect(mobileShell.mainTop).toBeGreaterThanOrEqual(mobileShell.headerBottom - 1);
});
test("mobile shell remains usable at supported narrow widths", async ({ page }) => {
await page.setViewportSize({ width: 430, height: 932 });
await mockApplication(page, session("user", true));
await page.goto("/index.html?ui=desktop");
const registeredViews = await page.evaluate(() => window.XiaobaiPages.pages.map((entry) => entry.id));
for (const viewport of [
{ width: 430, height: 932 },
{ width: 390, height: 844 },
{ width: 320, height: 720 },
]) {
await page.setViewportSize(viewport);
await expect(page.locator("body")).toHaveClass(/mobile-shell/);
for (const theme of ["light", "dark"]) {
for (const view of registeredViews) {
await page.evaluate(({ targetView, targetTheme }) => {
document.documentElement.dataset.theme = targetTheme;
openView(targetView);
}, { targetView: view, targetTheme: theme });
const geometry = await page.evaluate(() => ({
overflow: document.documentElement.scrollWidth - innerWidth,
visibleEntries: Array.from(document.querySelectorAll(".module-nav .mobile-primary-tab"))
.filter((entry) => getComputedStyle(entry).display !== "none").length,
smallestTarget: Math.min(...Array.from(document.querySelectorAll(".module-nav .mobile-primary-tab"))
.filter((entry) => getComputedStyle(entry).display !== "none")
.map((entry) => entry.getBoundingClientRect().height)),
}));
expect(geometry.overflow, `${view} overflows at ${viewport.width}px in ${theme} theme`).toBeLessThanOrEqual(1);
expect(geometry.visibleEntries).toBe(5);
expect(geometry.smallestTarget).toBeGreaterThanOrEqual(44);
}
}
await page.evaluate(() => openView("ladderView"));
await expect(page.locator("#mobileMarketSelector")).toBeVisible();
await expect(page.locator("#mobileMarketViewSelect option")).toHaveCount(12);
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
}
});
test("768px boundary uses the compact desktop shell", async ({ page }) => {
await page.setViewportSize({ width: 768, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.evaluate(() => openView("sentimentCycleView"));
await expect(page.locator("body")).not.toHaveClass(/mobile-shell/);
const geometry = await page.evaluate(() => {
const nav = document.querySelector(".module-nav").getBoundingClientRect();
const main = document.querySelector(".main").getBoundingClientRect();
return {
overflow: document.documentElement.scrollWidth - innerWidth,
navWidth: nav.width,
navRight: nav.right,
mainLeft: main.left,
mainRight: main.right,
};
});
expect(geometry.overflow).toBeLessThanOrEqual(1);
expect(geometry.navWidth).toBeGreaterThanOrEqual(44);
expect(geometry.navWidth).toBeLessThan(200);
expect(Math.abs(geometry.mainLeft - geometry.navRight)).toBeLessThanOrEqual(1);
expect(geometry.mainRight).toBeLessThanOrEqual(768);
});
test("native dialogs share one lifecycle and success feedback stays content-sized", async ({ page }) => {
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
const dialogIds = [
"strategyDrawer",
"tradeLogDialog",
"watchlistDialog",
"heavenReadingDialog",
"globalSearchDialog",
"entityDetailDialog",
"stockDialog",
"alertsDialog",
"assistantDialog",
"settingsDialog",
"adminDialog",
];
await page.evaluate(() => {
openModalDialog(document.querySelector("#tradeLogDialog"));
openModalDialog(document.querySelector("#watchlistDialog"));
});
await expect(page.locator("dialog[open]")).toHaveCount(1);
await expect(page.locator("#watchlistDialog")).toBeVisible();
await expect(page.locator("#tradeLogDialog")).toBeHidden();
await page.keyboard.press("Escape");
await expect(page.locator("dialog[open]")).toHaveCount(0);
await page.locator('[data-view="screenerView"]').first().click();
await page.locator('[data-screener-mode="quant"]').click();
for (const id of dialogIds) {
await page.evaluate((dialogId) => openModalDialog(document.getElementById(dialogId)), id);
await expect(page.locator(`#${id}`)).toBeVisible();
await expect(page.locator(`#${id} button[aria-label*="关闭"]`).first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator(`#${id}`)).toBeHidden();
}
await page.evaluate(() => showToast("交易记录已保存"));
const toastBox = await page.locator("#toast").boundingBox();
expect(toastBox.width).toBeLessThan(260);
expect(toastBox.height).toBeLessThan(80);
});
test("new review workflows render account-scoped records", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="screenerView"]').first().click();
await expect(page.locator('[data-screener-step="regime"]')).toHaveAttribute("data-state", "complete");
await page.locator('[data-screener-mode="quant"]').click();
await page.locator("#openStrategyDrawerButton").click();
await expect(page.locator("#strategyDrawer")).toBeVisible();
await expect(page.locator("#strategyNameInput")).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.locator("#strategyDrawer")).toBeHidden();
await expect(page.locator("#trackingTableBody tr")).toHaveCount(1);
await expect(page.locator("#trackingEmpty")).toBeHidden();
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
await expect(page.locator("#reviewDataDate")).toHaveText("2026-07-22");
const reviewJournal = await page.locator("#reviewWorkspaceView .journal-section").boundingBox();
const reviewLeft = await page.locator("#reviewWorkspaceView .review-left-stack").boundingBox();
expect(Math.abs(reviewJournal.width - 360)).toBeLessThanOrEqual(1);
expect(reviewJournal.x).toBeGreaterThan(reviewLeft.x + reviewLeft.width - 2);
expect(Math.abs((reviewJournal.y + reviewJournal.height) - (reviewLeft.y + reviewLeft.height))).toBeLessThanOrEqual(1);
await expect(page.locator("#watchlistTableBody tr")).toHaveCount(1);
await expect(page.locator("#watchlistTableBody tr").first().locator("td")).toHaveCount(8);
await expect(page.locator("#watchlistTableBody")).toContainText("+1.86");
await expect(page.locator("#watchlistTableBody")).toContainText("+8.92");
await expect(page.locator("#watchlistTableBody")).toContainText("72.4");
await expect(page.locator("#watchlistTableBody")).toContainText("观察承接,不追高");
const watchColumns = await page.locator("#reviewWorkspaceView .review-watchlist-table thead th").evaluateAll((headers) => ({
widths: headers.map((header) => Math.round(header.getBoundingClientRect().width)),
labels: headers.map((header) => header.textContent.replace(/[↕▲▼]/g, "").trim()),
numericAligned: headers.filter((header) => header.classList.contains("num")).every((header) => getComputedStyle(header).textAlign === "right"),
}));
expect(watchColumns.labels).toEqual(["标记", "股票", "所属板块", "今日涨幅(%", "5日涨幅(%", "竞价关注(分)", "跟踪备注", "操作"]);
expect(watchColumns.widths[6]).toBeGreaterThan(watchColumns.widths[3]);
expect(watchColumns.numericAligned).toBe(true);
await expect(page.locator("#journalSummary")).toHaveValue("缩量修复,主线仍待确认");
await expect(page.locator("#journalContent")).toHaveValue("做对了等待确认。");
await expect(page.locator("#journalPlan")).toHaveValue("只做有承接的核心。");
const journalSpacing = await page.evaluate(() => {
const textarea = document.querySelector("#journalContent");
const label = textarea.previousElementSibling;
const labelBox = label.getBoundingClientRect();
const textareaBox = textarea.getBoundingClientRect();
return { labelHeight: Math.round(labelBox.height), gap: Math.round(textareaBox.top - labelBox.bottom) };
});
expect(journalSpacing).toEqual({ labelHeight: 18, gap: 6 });
await page.screenshot({ path: "runtime/test-results/review-stage17-1440.png", fullPage: true });
await page.locator("#openWatchlistDialog").click();
await expect(page.locator("#watchlistDialog")).toBeVisible();
await page.locator("#watchlistSearchInput").fill("002141");
await expect(page.locator("#watchlistSearchResults [data-watchlist-result]")).toHaveCount(1);
await page.locator("#watchlistSearchResults [data-watchlist-result]").click();
await expect(page.locator("#watchlistSelectionName")).toHaveText("Test Stock");
await page.locator("#watchlistRemark").fill("等待放量确认");
await page.locator("#saveWatchlist").click();
await expect(page.locator("#watchlistDialog")).toBeHidden();
await expect(page.locator("#tradeLogTableBody tr").first().locator("td")).toHaveCount(9);
const tradeColumns = await page.locator("#reviewWorkspaceView .trade-log-table thead th").evaluateAll((headers) => ({
widths: headers.map((header) => Math.round(header.getBoundingClientRect().width)),
labels: headers.map((header) => header.textContent.replace(/[↕▲▼]/g, "").trim()),
}));
expect(tradeColumns.labels).toEqual(["日期", "股票", "动作", "仓位(%", "盈亏(%", "盈亏金额(元)", "情绪 / 标签", "交易复核", "操作"]);
expect(tradeColumns.widths[7]).toBeGreaterThan(tradeColumns.widths[3]);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
await expect(page.locator("#reviewHistoryPanel")).toBeHidden();
await page.locator("#reviewHistoryToggle").click();
await expect(page.locator("#reviewHistoryPanel")).toBeVisible();
await expect(page.locator("#reviewHistoryToggle")).toHaveAttribute("aria-expanded", "true");
const reviewHistoryOverflow = await page.evaluate(() => {
const history = document.querySelector("#notesHistory");
const seed = history.querySelector(".note-row");
for (let index = 0; index < 18; index += 1) history.appendChild(seed.cloneNode(true));
const main = document.querySelector(".app-main");
return {
mainClientHeight: main.clientHeight,
mainScrollHeight: main.scrollHeight,
mainOverflowY: getComputedStyle(main).overflowY,
historyClientHeight: history.clientHeight,
historyScrollHeight: history.scrollHeight,
historyOverflowY: getComputedStyle(history).overflowY,
};
});
expect(reviewHistoryOverflow.mainOverflowY).toBe("auto");
expect(reviewHistoryOverflow.mainScrollHeight).toBeGreaterThan(reviewHistoryOverflow.mainClientHeight);
expect(reviewHistoryOverflow.historyOverflowY).toBe("auto");
expect(reviewHistoryOverflow.historyScrollHeight).toBeGreaterThan(reviewHistoryOverflow.historyClientHeight);
await expect(page.locator("#tradeLogTableBody tr")).toHaveCount(1);
const tradeScroll = await page.evaluate(() => {
const seed = state.tradeEntries[0];
state.tradeEntries = Array.from({ length: 24 }, (_, index) => ({ ...seed, id: index + 1 }));
renderTradeLog();
const frame = document.querySelector("#reviewWorkspaceView .trade-log-table-frame");
return { scrollHeight: frame.scrollHeight, clientHeight: frame.clientHeight };
});
expect(tradeScroll.scrollHeight).toBeGreaterThan(tradeScroll.clientHeight);
await expect(page.locator("#tradeLogEmpty")).toBeHidden();
await expect(page.locator("#tradeLogForm")).toBeHidden();
await page.locator("#openTradeLogDialog").click();
await expect(page.locator("#tradeLogDialog")).toBeVisible();
await page.locator("#tradeLogCode").fill("002141");
await page.locator("#tradeLogName").fill("Test Stock");
await page.locator("#tradeLogPrice").fill("10.8");
await page.locator("#saveTradeLog").click();
await expect(page.locator("#tradeLogDialog")).toBeHidden();
await expect(page.locator("#reviewWorkspaceView")).toHaveClass(/active-view/);
await expect(page.locator("#tradeLogTableBody tr")).toHaveCount(1);
await page.locator('[data-trade-action="edit"]').click();
await expect(page.locator("#tradeLogDialogTitle")).toHaveText("编辑交易日志");
await page.locator("#cancelTradeEdit").click();
await expect(page.locator("#tradeLogDialog")).toBeHidden();
await page.setViewportSize({ width: 375, height: 812 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
const mobileLeft = await page.locator("#reviewWorkspaceView .review-left-stack").boundingBox();
const mobileJournal = await page.locator("#reviewWorkspaceView .journal-section").boundingBox();
expect(mobileJournal.y).toBeGreaterThanOrEqual(mobileLeft.y + mobileLeft.height - 2);
await page.setViewportSize({ width: 1440, height: 900 });
await page.locator("#alertButton").click();
await expect(page.locator("#alertList .alert-item")).toHaveCount(1);
await expect(page.locator("#alertBadge")).toHaveText("1");
await page.locator("#closeAlertsDialog").click();
await page.locator("#assistantButton").click();
await expect(page.locator("#assistantMemberGate")).toBeHidden();
await expect(page.locator("#assistantMemberContent")).toHaveAttribute("aria-disabled", "false");
await expect(page.locator("#assistantMessages .assistant-message")).toHaveCount(1);
await expect(page.locator("#assistantQuestion")).toBeEnabled();
});
test("curated strategies and quant builder form independent screener workspaces", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="screenerView"]').first().click();
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator('[data-screener-panel="smart"]')).toBeHidden();
await expect(page.locator('[data-screener-panel="curated"]')).toBeVisible();
await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(1);
await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量");
await expect(page.locator("#curatedFilterList .curated-rule-row")).toHaveCount(1);
await expect(page.locator("#curatedRunButton")).toHaveCount(0);
await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4);
await page.locator('[data-screener-mode="quant"]').click();
await expect(page.locator('[data-screener-panel="curated"]')).toBeHidden();
await expect(page.locator('[data-screener-panel="quant"]')).toBeVisible();
await expect(page.locator("#quantFilterRows .quant-filter-row")).toHaveCount(2);
await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5);
await expect(page.locator('#quantScoreRows input[data-quant-key="weight"]').first()).toHaveAttribute("type", "range");
await expect(page.locator("#quantWeightTotal")).toHaveText("100%");
await expect(page.locator("#quantRunButton")).toBeEnabled();
await page.locator("#addQuantFilterButton").click();
await expect(page.locator("#quantFilterRows .quant-filter-row")).toHaveCount(3);
const lastScore = page.locator("#quantScoreRows .quant-score-row").last();
await expect(lastScore.locator('[data-quant-action="direction"]')).toHaveText("数值越低越优");
await lastScore.locator('[data-quant-action="direction"]').click();
await expect(lastScore.locator('[data-quant-action="direction"]')).toHaveText("数值越高越优");
});
test("screener refresh restores every mode and deduplicates setup loading", async ({ page }) => {
const candidate = (code, name) => ({
code, name, sector: "测试板块", score_display: 80, historical_probability: 50,
probability_samples: 20, pct_chg: 1, return_5d: 2, volume_ratio_5d: 1.2,
sector_strength: 70, reason: "测试来源", risk_flags: [],
});
const result = (mode, runId, strategyName, row) => ({
meta: {
run_id: runId, trade_date: "20260722", regime: "repair",
strategy_name: strategyName, mode,
},
candidates: [row],
disclaimer: "历史统计不代表未来收益",
backtest: null,
});
const options = {
latestScreenerResults: {
smart: result("smart", 61, "修复确认", candidate("600001", "阶段恢复")),
curated: result("curated", 62, "连续分红质量", candidate("600002", "策略恢复")),
quant: result("quant", 63, "自定义量化公式", candidate("600003", "量化恢复")),
},
};
await mockApplication(page, session("user", true), options);
await page.goto("/index.html?view=screenerView");
await expect(page.locator("#screenerTableBody")).toContainText("阶段恢复");
expect(options.screenerSetupRequests).toBe(1);
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量");
await expect(page.locator("#screenerTableBody")).toContainText("策略恢复");
await page.locator('[data-screener-mode="quant"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("量化恢复");
await page.reload();
await expect(page.locator('[data-screener-mode="quant"]')).toHaveClass(/active/);
await expect(page.locator("#screenerTableBody")).toContainText("量化恢复");
expect(options.screenerSetupRequests).toBe(2);
await page.locator("#quantRunButton").click();
await expect.poll(() => options.screenerRunBodies?.length || 0).toBe(1);
expect(options.screenerRunBodies[0].mode).toBe("quant");
});
test("screener redesign preserves three clear workspaces across desktop and mobile", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="screenerView"]').first().click();
await expect(page.locator("#screenerView .screener-page-bar")).toBeVisible();
await expect(page.locator("#screenerView .screener-step")).toHaveCount(4);
await expect(page.locator("#regimeLabel")).toHaveText("修复");
await expect(page.locator("#screenerView .regime-temperature")).toHaveCount(0);
await expect(page.locator("#regimeEvidenceList")).toContainText("情绪温度");
const overviewCards = page.locator("#screenerView .screener-overview-card");
await expect(overviewCards).toHaveCount(2);
const [regimeBox, strategyBox] = await Promise.all([
overviewCards.nth(0).boundingBox(),
overviewCards.nth(1).boundingBox(),
]);
expect(Math.abs(regimeBox.y - strategyBox.y)).toBeLessThanOrEqual(1);
expect(strategyBox.x).toBeGreaterThan(regimeBox.x + regimeBox.width - 2);
expect(regimeBox.height).toBeLessThanOrEqual(155);
expect(strategyBox.height).toBeLessThanOrEqual(155);
for (const line of await page.locator("#screenerView .step-line").all()) {
expect((await line.boundingBox()).width).toBeLessThanOrEqual(42);
}
const [strategyDescriptionBox, strategyActionsBox] = await Promise.all([
page.locator("#activeStrategyDescription").boundingBox(),
page.locator(".screener-strategy-actions").boundingBox(),
]);
expect(strategyActionsBox.y - (strategyDescriptionBox.y + strategyDescriptionBox.height)).toBeLessThanOrEqual(8);
const trackingEntryStyle = await page.locator("#openScreenerTrackingButton").evaluate((element) => {
const style = getComputedStyle(element);
return { background: style.backgroundColor, weight: style.fontWeight };
});
expect(trackingEntryStyle.background).not.toBe("rgb(255, 255, 255)");
expect(Number(trackingEntryStyle.weight)).toBeGreaterThanOrEqual(700);
await page.evaluate(() => {
setScreenerResult("smart", {
meta: { run_id: 46, trade_date: "20260722", realtime: false, regime: "repair", strategy_name: "修复确认" },
disclaimer: "历史统计不代表未来收益",
candidates: [{
code: "600000", ts_code: "600000.SH", name: "浦发银行", sector: "银行", price: 12,
score_display: 82, historical_probability: 45, probability_samples: 30,
pct_chg: 1.2, return_5d: 3.4, volume_ratio_5d: 1.5, sector_strength: 78,
reason: "板块强度、相对强度", risk_flags: [],
}],
backtest: {
samples: 35, win_rate: 8.6, average_3d_return: -5,
average_drawdown: -11.71, definition: "收盘后选股,未来3日按统一阈值验证。",
},
}, { regime: "repair", strategyId: 1, strategyName: "修复确认" });
renderScreenerResult();
});
const completedMarkers = page.locator('#screenerView .screener-step[data-state="complete"] .step-marker');
await expect(completedMarkers).toHaveCount(3);
await expect(page.locator("#backtestPanel")).toBeVisible();
expect((await page.locator("#backtestPanel").boundingBox()).height).toBeLessThanOrEqual(55);
await expect(page.locator("#backtestMetrics .dragon-metric span")).toHaveCount(4);
await expect(page.locator("#screenerResultSource")).toHaveText("阶段选股 · 修复 · 修复确认");
for (const metric of await page.locator("#backtestMetrics .dragon-metric").all()) {
const box = await metric.boundingBox();
expect(box.height).toBeLessThanOrEqual(40);
}
await page.screenshot({ path: "runtime/test-results/screener-stage15-phase-1440.png", fullPage: true });
await page.evaluate(() => {
state.screenerSetup.strategies.push({
id: 4, name: "低波质量", description: "用第二套策略验证整卡选择交互。", regimes: ["repair"], builtin: true,
data_ready: true, missing_data: [],
formula: {
meta: { library: "curated", category: "质量防守", quality: "A", frequency: "月度", risk: "低", data_group: "行情与质量" },
universe: { exclude_st: true, listed_days_min: 720 },
filters: [{ field: "return_20d", op: ">=", value: 0 }],
score: [{ field: "relative_strength", weight: 1, direction: "desc" }], limit: 20, min_score: 0.5,
},
});
renderCuratedStrategyLibrary();
});
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(2);
await expect(page.locator(".curated-detail-pane")).toBeVisible();
const secondStrategy = page.locator('#curatedStrategyList [data-curated-strategy="4"]');
await secondStrategy.click();
await expect(secondStrategy).toHaveClass(/active/);
await expect(page.locator('#curatedStrategyList [data-curated-strategy="2"]')).not.toHaveClass(/active/);
await expect(page.locator("#curatedStrategyName")).toHaveText("低波质量");
const [libraryBox, detailBox] = await Promise.all([
page.locator(".curated-library-pane").boundingBox(),
page.locator(".curated-detail-pane").boundingBox(),
]);
expect(detailBox.x).toBeGreaterThan(libraryBox.x + libraryBox.width - 2);
expect(Math.abs(detailBox.y - libraryBox.y)).toBeLessThanOrEqual(1);
expect(libraryBox.width).toBeLessThan(detailBox.width);
await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4);
await page.screenshot({ path: "runtime/test-results/screener-stage15-strategy-1440.png", fullPage: true });
await page.locator('[data-screener-mode="quant"]').click();
await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5);
await expect(page.locator("#screenerView .quant-intro-band")).toHaveCount(0);
await expect(page.getByText("执行设置", { exact: true })).toHaveCount(0);
await expect(page.locator("#screenerResultTitle")).toHaveText("自定义选股结果");
const [builderBox, summaryBox] = await Promise.all([
page.locator(".quant-builder-pane").boundingBox(),
page.locator(".quant-summary-pane").boundingBox(),
]);
expect(Math.abs(builderBox.y - summaryBox.y)).toBeLessThanOrEqual(1);
expect(summaryBox.x).toBeGreaterThan(builderBox.x + builderBox.width - 2);
expect(builderBox.width).toBeGreaterThanOrEqual(490);
expect(builderBox.width).toBeLessThanOrEqual(540);
const quantRunBox = await page.locator("#quantRunButton").boundingBox();
expect(quantRunBox.width).toBeLessThan(180);
expect((await page.locator("#quantFilterRows .quant-filter-row select").first().boundingBox()).width).toBeLessThanOrEqual(225);
await expect(page.locator('[data-screener-results-slot="quant"] > .screener-results-view')).toBeVisible();
await page.screenshot({ path: "runtime/test-results/screener-stage15-quant-1440.png", fullPage: true });
await page.setViewportSize({ width: 375, height: 812 });
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator("#screenerView .screener-mode-tabs")).toBeVisible();
await page.locator('[data-screener-mobile-view="results"]').click();
await expect(page.locator("#screenerView .screener-results-view")).toBeVisible();
await expect(page.locator("#screenerEmpty")).toBeVisible();
});
test("automatic screener results stay read-only and mode results stay isolated", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="screenerView"]').first().click();
await page.evaluate(() => {
state.screenerSetup.regimes.push({ id: "retreat", label: "退潮" });
state.screenerSetup.strategies.push({
id: 3, name: "退潮防守", description: "仅保留抗跌方向", regimes: ["retreat"], builtin: true,
data_ready: true, missing_data: [],
formula: { meta: { library: "smart" }, universe: {}, filters: [], score: [], limit: 10, min_score: 0.5 },
});
const candidate = (code, name) => ({
code, name, sector: "测试板块", score_display: 80, historical_probability: 50,
probability_samples: 20, pct_chg: 1, return_5d: 2, volume_ratio_5d: 1.2,
sector_strength: 70, reason: "测试来源", risk_flags: [],
});
window.__screenerCandidate = candidate;
setScreenerResult("smart", {
meta: { run_id: 51, trade_date: "20260722", regime: "repair", strategy_name: "修复确认" },
disclaimer: "历史统计不代表未来收益", candidates: [candidate("600001", "阶段结果")],
backtest: { samples: 37, win_rate: 50, average_3d_return: 1, average_drawdown: -2, definition: "测试" },
}, { regime: "repair", strategyId: 1, strategyName: "修复确认" });
renderScreenerSetup();
});
await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(3);
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
await expect(page.locator('[data-regime]')).toHaveCount(0);
await expect(page.locator("#screenerRunButton")).toHaveCount(0);
await expect(page.locator("#syncScreenerButton")).toHaveCount(0);
await expect(page.locator("#changeStrategyButton")).toHaveCount(0);
await expect(page.locator("#editStrategyButton")).toHaveCount(0);
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#screenerEmpty")).toContainText("尚无成功发布");
await page.evaluate(() => {
setScreenerResult("curated", {
meta: { run_id: 52, trade_date: "20260722", regime: "repair", strategy_name: "连续分红质量" },
disclaimer: "历史统计不代表未来收益", candidates: [window.__screenerCandidate("600002", "策略结果")],
}, { regime: "repair", strategyId: 2, strategyName: "连续分红质量" });
renderScreenerResult();
});
await expect(page.locator("#screenerTableBody")).toContainText("策略结果");
await expect(page.locator("#screenerTableBody")).not.toContainText("阶段结果");
await expect(page.locator("#screenerResultSource")).toHaveText("策略选股 · 连续分红质量");
await page.locator('[data-screener-mode="quant"]').click();
await expect(page.locator("#screenerEmpty")).toContainText("自定义选股");
await page.evaluate(() => {
setScreenerResult("quant", {
meta: { run_id: 53, trade_date: "20260722", regime: "repair", strategy_name: "自定义量化公式" },
disclaimer: "历史统计不代表未来收益", candidates: [window.__screenerCandidate("600003", "量化结果")],
}, { regime: "repair", strategyName: "自定义量化公式" });
renderScreenerResult();
});
await expect(page.locator("#screenerTableBody")).toContainText("量化结果");
await expect(page.locator("#screenerResultSource")).toHaveText("自定义选股 · 自定义因子权重");
await page.locator('[data-screener-mode="smart"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
await expect(page.locator("#screenerTableBody")).not.toContainText("策略结果");
await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果");
});
test("screener publishes atomically and exposes latest, active, and historical candidates", async ({ page }) => {
const candidate = {
code: "600001", ts_code: "600001.SH", name: "已发布候选", sector: "电力设备", price: 12,
score_display: 82, historical_probability: 45, probability_samples: 30,
pct_chg: 1.2, return_5d: 3.4, volume_ratio_5d: 1.5, sector_strength: 78,
reason: "板块强度、相对强度", risk_flags: [],
};
const smartResult = {
meta: {
run_id: 201, trade_date: "20260803", regime: "repair",
strategy_name: "修复确认", mode: "smart",
},
candidates: [candidate],
disclaimer: "历史统计不代表未来收益",
backtest: null,
};
const activeRow = {
mode: "smart", code: candidate.code, name: candidate.name, sector: candidate.sector,
selection_date: "20260803", score_display: candidate.score_display,
hits: [{
strategy_name: "修复确认", selection_date: "20260803", active: true,
validity: { label: "阶段内有效" }, valid_until: "", remaining_trading_days: null,
score_display: candidate.score_display,
}],
};
const expiredRow = {
mode: "smart", code: "600002", name: "历史候选", sector: "银行",
selection_date: "20260727", score_display: 76,
hits: [{
strategy_name: "修复确认", selection_date: "20260727", active: false,
validity: { label: "阶段内有效" }, valid_until: "20260801", remaining_trading_days: 0,
score_display: 76,
}],
};
const options = {
latestScreenerResults: { smart: smartResult },
recentScreenerResults: [smartResult],
screenerPublishedRuns: {
修复确认: { trade_date: "20260803", status: "ready", detail: "1 只候选" },
连续分红质量: { trade_date: "20260803", status: "no_signal", detail: "数据完整,暂无符合条件个股" },
数据缺失策略: { trade_date: "20260803", status: "missing_data", detail: "缺少近5日资金流" },
},
additionalScreenerStrategies: [{
id: 4, name: "数据缺失策略", description: "验证数据不足状态", regimes: ["repair"],
builtin: true, data_ready: false, missing_data: ["近5日资金流"],
formula: {
meta: { library: "curated", category: "资金动量", quality: "A", frequency: "每日", risk: "中" },
universe: { exclude_st: true }, filters: [], score: [], limit: 20, min_score: 0.5,
},
}],
screenerSetupOverride: {
trade_date: "20260804",
requested_trade_date: "20260804",
published_batch: {
trade_date: "20260803", status: "complete", is_fallback: true,
completed_count: 28, skipped_count: 1, finished_at: "2026-08-03T15:30:00+08:00",
notice: "所选日期候选正在生成,当前保留上一成功批次",
},
automatic_status: {
trade_date: "20260804", status: "running", retaining_trade_date: "20260803",
},
active_signals: [activeRow],
candidate_history: [activeRow, expiredRow],
},
};
await page.setViewportSize({ width: 1920, height: 1080 });
await mockApplication(page, session("user", true), options);
await page.goto("/index.html?view=screenerView");
await expect(page.locator("#screenerDateLabel")).toContainText("候选批次 2026-08-03");
await expect(page.locator("#screenerBatchNotice")).toContainText("当前保留上一成功批次");
await expect(page.locator("#screenerTableBody")).toContainText(candidate.name);
await page.locator('[data-screener-archive-view="active"]').click();
await expect(page.locator("#screenerResultTitle")).toHaveText("持续有效信号");
await expect(page.locator("#screenerArchiveTableBody")).toContainText(candidate.name);
await expect(page.locator("#screenerArchiveTableBody")).not.toContainText("历史候选");
await page.locator('[data-screener-archive-view="history"]').click();
await expect(page.locator("#screenerResultTitle")).toHaveText("历史入选记录");
await expect(page.locator("#screenerArchiveTableBody")).toContainText(candidate.name);
await expect(page.locator("#screenerArchiveTableBody")).toContainText("历史候选");
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#screenerEmpty")).toHaveText("数据完整,暂无符合条件个股");
await expect(page.locator("#screenerTableBody")).not.toContainText(candidate.name);
await page.locator('[data-curated-strategy="4"]').click();
await expect(page.locator("#screenerEmpty")).toContainText("缺少必需数据");
await expect(page.locator("#curatedDataStatus")).toContainText("缺少近5日资金流");
await page.locator('[data-screener-mode="smart"]').click();
await page.reload();
await expect(page.locator("#screenerTableBody")).toContainText(candidate.name);
await expect(page.locator("#screenerBatchNotice")).toContainText("当前保留上一成功批次");
expect(options.screenerSetupRequests).toBe(2);
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('[data-screener-mobile-view="results"]').click();
await expect(page.locator("#screenerView .screener-results-view")).toBeVisible();
await expect(page.locator("#screenerTableBody")).toContainText(candidate.name);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1);
await page.locator("#headerMenuButton").click();
await page.locator('[data-mobile-command-target="themeToggle"]').click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(page.locator("#screenerView .screener-results-view")).toBeVisible();
await page.locator("#headerMenuButton").click();
await page.locator('[data-mobile-command-target="themeToggle"]').click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
});
test("screener restores automatic stage and curated pools across switching and reload", async ({ page }) => {
const formula = {
meta: { library: "smart" }, universe: {}, filters: [],
score: [{ field: "relative_strength", weight: 1, direction: "desc" }],
limit: 10, min_score: 0.5,
};
const candidate = (code, name) => ({
code, name, sector: "Test Sector", score_display: 80,
historical_probability: 50, probability_samples: 20, pct_chg: 1,
return_5d: 2, volume_ratio_5d: 1.2, sector_strength: 70,
reason: "Context result", risk_flags: [],
});
const result = (mode, runId, strategyName, row) => ({
meta: {
run_id: runId, trade_date: "20260722", regime: "repair",
strategy_name: strategyName, mode,
},
candidates: [row],
disclaimer: "Historical statistics do not predict future returns.",
backtest: null,
});
const smartResult = result("smart", 101, "修复确认", candidate("600001", "Smart Repair"));
const curatedA = result("curated", 102, "连续分红质量", candidate("600002", "Curated A"));
const curatedB = result("curated", 103, "Quality B", candidate("600003", "Curated B"));
const options = {
latestScreenerResults: { smart: smartResult, curated: curatedA },
recentScreenerResults: [smartResult, curatedA, curatedB],
additionalScreenerStrategies: [
{
id: 4, name: "Quality B", description: "Second curated strategy",
regimes: ["repair"], builtin: true, data_ready: true, missing_data: [],
formula: {
...formula,
meta: { library: "curated", category: "Quality", quality: "A", frequency: "Monthly", risk: "Low" },
},
},
],
};
await mockApplication(page, session("user", true), options);
await page.goto("/index.html?view=screenerView");
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
await expect(page.locator("#screenerRunButton")).toHaveCount(0);
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
await page.locator('[data-curated-strategy="4"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
await page.locator('[data-curated-strategy="2"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
expect(options.screenerRunBodies || []).toHaveLength(0);
await page.reload();
await page.locator('[data-screener-mode="smart"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
await page.locator('[data-curated-strategy="4"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
});
test("screener tracking is an internal page populated only by manual candidate actions", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="screenerView"]').first().click();
await page.evaluate(() => {
setScreenerResult("smart", {
meta: { run_id: 45, trade_date: "20260722", realtime: false, regime: "repair", strategy_name: "修复确认" },
disclaimer: "历史统计不代表未来收益",
candidates: [{
code: "600000", ts_code: "600000.SH", name: "浦发银行", sector: "银行", price: 12,
score_display: 82, historical_probability: 45, probability_samples: 30,
pct_chg: 1.2, return_5d: 3.4, volume_ratio_5d: 1.5, sector_strength: 78,
reason: "板块强度、相对强度", risk_flags: [],
}],
}, { regime: "repair", strategyId: 1, strategyName: "修复确认" });
renderScreenerResult();
});
const addButton = page.locator('[data-add-tracking="600000"]');
await expect(addButton).toHaveText("加入跟踪");
await addButton.click();
await expect(page.locator('[data-add-tracking="600000"]')).toHaveText("已跟踪");
await page.locator("#openScreenerTrackingButton").click();
await expect(page.locator("#screenerTrackingView")).toHaveClass(/active-view/);
await expect(page.locator('[data-view="screenerView"]').first()).toHaveClass(/active/);
await expect(page.locator("#trackingTableBody tr")).toHaveCount(2);
await expect(page.locator('[data-view="screenerTrackingView"]')).toHaveCount(0);
await page.screenshot({ path: "runtime/test-results/screener-stage15-tracking-1440.png", fullPage: true });
page.once("dialog", (dialog) => dialog.accept());
await page.locator('[data-remove-tracking="10"]').click();
await expect(page.locator("#trackingTableBody tr")).toHaveCount(1);
await page.locator("#closeScreenerTrackingButton").click();
await expect(page.locator("#screenerView")).toHaveClass(/active-view/);
});
for (const viewport of [
{ name: "portrait", width: 375, height: 812 },
{ name: "landscape", width: 812, height: 375 },
]) {
test(`member workflows fit a mobile ${viewport.name} viewport`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await mockApplication(page, session("user", true));
await page.goto("/index.html?ui=desktop");
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator("#openTradeLogDialog")).toBeVisible();
await expect(page.locator("#tradeLogForm")).toBeHidden();
await page.locator("#openTradeLogDialog").click();
await expect(page.locator("#tradeLogDialog")).toBeVisible();
await expect(page.locator("#tradeLogForm")).toBeVisible();
const dialogWidth = await page.locator("#tradeLogDialog").evaluate((dialog) => dialog.getBoundingClientRect().width);
expect(dialogWidth).toBeLessThanOrEqual(viewport.width);
await page.locator("#closeTradeLogDialog").click();
await page.evaluate(() => {
state.heavenInterpretations.fortune = {
id: 31,
subject: "2026-07-23 观气",
subject_detail: "丙午年 · 乙未月 · 己丑日",
answer: "当日解运结果",
context_date: "20260723",
created_at: "2026-07-23T09:12:00+08:00",
};
openHeavenReading("fortune", { loading: false });
});
await expect(page.locator("#heavenReadingDialog")).toBeVisible();
const readingWidth = await page.locator("#heavenReadingDialog").evaluate((dialog) => dialog.getBoundingClientRect().width);
expect(readingWidth).toBeLessThanOrEqual(viewport.width);
await page.locator("#closeHeavenReadingDialog").click();
});
}
test("reduced-motion preference suppresses continuous animation", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
expect(await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true);
const motion = await page.locator(".sentiment-gauge").evaluate((element) => {
const style = getComputedStyle(element, "::after");
return { duration: style.animationDuration, iterations: style.animationIterationCount };
});
expect(Number.parseFloat(motion.duration)).toBeLessThanOrEqual(0.00001);
expect(motion.iterations).toBe("1");
});
test("heaven workspace actions remain compact and do not overlap", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="heavenView"]').first().click();
const titleSize = Number.parseFloat(await page.locator("#heavenView .wt-title-line h1").evaluate((element) => getComputedStyle(element).fontSize));
expect(titleSize).toBeLessThanOrEqual(32);
const trendButtons = page.locator(".heaven-trend-actions .button");
await expect(trendButtons).toHaveCount(3);
for (let index = 0; index < await trendButtons.count(); index += 1) {
const box = await trendButtons.nth(index).boundingBox();
expect(box.width).toBeLessThanOrEqual(130);
expect(box.height).toBeLessThanOrEqual(44);
}
await page.locator('[data-heaven-panel="fortune"]').click();
const fortuneActions = await page.locator("#heavenFortunePanel .fortune-heading-actions").boundingBox();
const fortunePanel = await page.locator("#heavenFortunePanel").boundingBox();
expect(fortuneActions.x + fortuneActions.width).toBeLessThanOrEqual(fortunePanel.x + fortunePanel.width + 1);
await page.evaluate(() => {
state.heavenInterpretations.fortune = {
id: 31,
mode: "fortune",
context_date: "20260723",
subject: "2026-07-23 观气",
subject_detail: "丙午年 · 乙未月 · 己丑日",
answer: "三层气机已经合参,今日宜先定节奏,再看行动。",
created_at: "2026-07-23T09:12:00+08:00",
};
openHeavenReading("fortune", { loading: false });
});
const readingDialog = await page.locator("#heavenReadingDialog").boundingBox();
expect(readingDialog.width).toBeLessThanOrEqual(1120);
expect(readingDialog.width / readingDialog.height).toBeGreaterThan(1.4);
expect(readingDialog.height).toBeLessThanOrEqual(820);
const readingHeader = await page.locator("#heavenReadingDialog .dialog-header").boundingBox();
const readingTabs = await page.locator("#heavenReadingDialog .heaven-reading-tabs").boundingBox();
expect(readingHeader.y + readingHeader.height).toBeLessThanOrEqual(readingTabs.y + 1);
await page.locator("#closeHeavenReadingDialog").click();
await page.locator('[data-heaven-panel="heart"]').click();
const heartControls = await page.locator(".heart-toolbar-controls").boundingBox();
const heartPanel = await page.locator("#heavenHeartPanel").boundingBox();
expect(heartControls.width).toBeLessThanOrEqual(190);
expect(heartControls.x + heartControls.width).toBeLessThanOrEqual(heartPanel.x + heartPanel.width + 1);
const historyBox = await page.locator("#historyHeartButton").boundingBox();
const soundBox = await page.locator("#heartSoundToggle").boundingBox();
expect(historyBox.width).toBeLessThanOrEqual(100);
expect(historyBox.x + historyBox.width).toBeLessThanOrEqual(soundBox.x);
});
test("heaven workspace controls fit a narrow viewport", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await mockApplication(page, session("user", true));
await page.goto("/index.html?ui=desktop");
await page.locator('[data-view="heavenView"]').first().click();
expect(await page.evaluate(() => window.scrollY)).toBe(0);
const trendActions = await page.locator(".heaven-trend-actions").boundingBox();
expect(trendActions.width).toBeLessThanOrEqual(347);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
await page.locator('[data-heaven-panel="fortune"]').click();
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
const fortuneButtons = page.locator("#heavenFortunePanel .fortune-heading-actions .button");
const firstFortuneButton = await fortuneButtons.first().boundingBox();
const secondFortuneButton = await fortuneButtons.last().boundingBox();
expect(Math.abs(firstFortuneButton.width - secondFortuneButton.width)).toBeLessThanOrEqual(1);
expect(Math.abs(firstFortuneButton.y - secondFortuneButton.y)).toBeLessThanOrEqual(1);
await page.locator('[data-heaven-panel="heart"]').click();
const heartControls = await page.locator(".heart-toolbar-controls").boundingBox();
const heartPanel = await page.locator("#heavenHeartPanel").boundingBox();
expect(heartControls.width).toBeLessThanOrEqual(heartPanel.width);
expect(heartControls.x).toBeGreaterThanOrEqual(heartPanel.x - 1);
expect(heartControls.x + heartControls.width).toBeLessThanOrEqual(375);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
});
test("mentor directory exposes evidence filters and private owner metadata", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await expect(page.locator("#mentorView .mentor-sidebar .mentor-directory-tools")).toBeVisible();
await expect(page.locator("#mentorFilterToggle")).toBeVisible();
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22);
await expect(page.locator('#mentorList [data-mentor-id="private-owner"] .mentor-badge.private')).toContainText("仅自己");
await expect(page.locator('#mentorList [data-mentor-id="source-a"] .mentor-badge.grade-a')).toHaveText("A级");
await expect(page.locator('#mentorList [data-mentor-id="source-c"] .mentor-badge.quality')).toHaveCount(0);
await page.locator("#mentorSearchInput").fill("行为推演");
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(1);
await expect(page.locator("#mentorCount")).toHaveText("1 / 22 位");
await page.locator("#mentorSearchInput").fill("");
await page.locator("#mentorFilterToggle").click();
await page.locator('[data-mentor-grade="B"]').click();
await expect(page.locator("#mentorFilterOptions [data-mentor-grade].active")).toHaveText("B级");
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(7);
await page.locator('#mentorList [data-mentor-id="source-b"]').click();
await expect(page.locator("#activeMentorName")).toHaveText("多源老师");
await expect(page.locator("#activeMentorStatus")).toContainText("确认之后再行动");
const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox();
const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox();
const mentorInput = await page.locator("#mentorQuestion").boundingBox();
const mentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox();
expect(Math.abs(mentorLibrary.width - 300)).toBeLessThanOrEqual(1);
expect(Math.abs(mentorChat.x - (mentorLibrary.x + mentorLibrary.width))).toBeLessThanOrEqual(1);
expect(Math.abs(mentorChat.x + mentorChat.width - (mentorLayout.x + mentorLayout.width))).toBeLessThanOrEqual(1);
const composerForm = await page.locator("#mentorView .mentor-chat-form").boundingBox();
expect(composerForm.height).toBeGreaterThanOrEqual(81);
expect(composerForm.height).toBeLessThanOrEqual(91);
expect(mentorInput.height).toBeGreaterThanOrEqual(24);
expect(mentorInput.height).toBeLessThanOrEqual(34);
const searchField = await page.locator("#mentorView .mentor-search-field").boundingBox();
expect(searchField.width).toBeGreaterThan(160);
await expect(page.locator("#mentorSearchInput")).toHaveAttribute("placeholder", "搜索联系人或标签");
await expect(page.locator(".module-nav")).toBeVisible();
await expect(page.locator(".module-tab.active")).toHaveAttribute("data-view", "mentorView");
await expect(page.locator(".market-tape")).toBeVisible();
await expect(page.locator("#tradeDate")).toBeVisible();
await expect(page.locator(".overview-strip")).toBeVisible();
await expect(page.locator(".status-bar")).toBeVisible();
await expect(page.locator("#themeToggle")).toBeVisible();
await expect(page.locator("#mentorView .mentor-page-title h2")).toHaveText("问师");
await expect(page.locator("#mentorPageSubtitle")).toContainText("数据日期");
expect(await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)).toBeLessThanOrEqual(1);
await page.setViewportSize({ width: 1920, height: 947 });
const expandedMentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox();
const footerBar = await page.locator("#mentorView .mentor-workspace-footer").boundingBox();
const shellStatusBar = await page.locator(".status-bar").boundingBox();
expect(Math.abs(shellStatusBar.y + shellStatusBar.height - 947)).toBeLessThanOrEqual(1);
expect(footerBar.y + footerBar.height).toBeLessThanOrEqual(shellStatusBar.y);
expect(Math.abs(expandedMentorLayout.y + expandedMentorLayout.height - footerBar.y)).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)).toBeLessThanOrEqual(1);
});
test("mentor stays inside the project shell and switching pages leaves no residue", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await expect(page.locator("#mentorView")).toHaveClass(/active-view/);
await expect(page.locator(".module-nav")).toBeVisible();
await expect(page.locator(".module-tab.active")).toHaveAttribute("data-view", "mentorView");
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22);
await expect(page.locator("#mentorView .mentor-page-title h2")).toHaveText("问师");
await page.locator('[data-view="limitPool"]').first().click();
await expect(page.locator("#limitPool")).toHaveClass(/active-view/);
await expect(page.locator("#mentorView")).not.toHaveClass(/active-view/);
await expect(page.locator("#mentorView")).not.toBeVisible();
await expect(page.locator(".module-nav")).toBeVisible();
await expect(page.locator(".module-tab.active")).toHaveAttribute("data-view", "limitPool");
await page.locator('[data-view="mentorView"]').first().click();
await expect(page.locator("#mentorView")).toHaveClass(/active-view/);
await expect(page.locator(".module-nav")).toBeVisible();
await expect(page.locator(".module-tab.active")).toHaveAttribute("data-view", "mentorView");
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22);
await expect(page.locator("#mentorPageSubtitle")).toContainText("数据日期");
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
});
test("mentor keeps two columns and opens the profile floating dialog on desktop", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await page.locator('#mentorList [data-mentor-id="source-b"]').click();
await expect(page.locator("#mentorView .mentor-profile-panel")).toHaveCount(0);
const mentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox();
const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox();
const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox();
expect(Math.abs(mentorChat.x - (mentorLibrary.x + mentorLibrary.width))).toBeLessThanOrEqual(1);
expect(Math.abs(mentorChat.x + mentorChat.width - (mentorLayout.x + mentorLayout.width))).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
await page.locator("#mentorProfileButton").click();
await expect(page.locator("#mentorProfileDialog")).toBeVisible();
await expect(page.locator("#mentorProfileDialogName")).toHaveText("多源老师");
await expect(page.locator("#mentorProfileDialogEvidence")).toHaveText("公开访谈与多源材料");
await page.locator('[data-mentor-dialog-close="mentorProfileDialog"]').click();
await expect(page.locator("#mentorProfileDialog")).not.toBeVisible();
});
test("mentor pins, custom order and streamed replies work together", async ({ page }) => {
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await page.locator('[data-mentor-id="source-c"]').click();
await page.locator("#mentorPinButton").click();
await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-c");
await expect(page.locator('#mentorList [data-mentor-card="source-c"] .mentor-badge.pinned')).toHaveText("置顶");
await page.locator('[data-mentor-id="source-b"]').click();
await page.locator("#mentorPinButton").click();
await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-b");
await page.locator("#mentorSortToggle").click();
await page.locator('[data-mentor-target="source-b"][data-mentor-move="down"]').click();
await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-c");
await page.locator('[data-mentor-id="source-c"]').click();
await page.locator("#mentorQuestion").fill("现在怎么看?");
await page.locator("#sendMentorQuestion").click();
const answer = page.locator("#mentorMessages .mentor-message.assistant").last();
await expect(answer).toContainText("先看市场结构。");
await expect(answer.locator(".mentor-answer-list li")).toHaveCount(2);
await expect(answer.locator("br")).toHaveCount(0);
await expect(page.locator("#mentorMessages .assistant-stream-caret")).toHaveCount(0);
const followUps = answer.locator("[data-mentor-follow-up]");
await expect(followUps).toHaveCount(3);
await expect(followUps.first().locator(".lucide")).toHaveCount(0);
await expect(answer.locator("small")).toHaveCount(0);
const messageCount = await page.locator("#mentorMessages .mentor-message").count();
await followUps.first().click();
await expect(page.locator("#mentorQuestion")).toHaveValue("哪些信号代表确认?");
await expect(page.locator("#mentorMessages .mentor-message")).toHaveCount(messageCount);
const userLabel = page.locator("#mentorMessages .mentor-message.user .mentor-message-label").first();
await expect(userLabel).not.toContainText("我 ·");
const assistantBody = await page.locator("#mentorMessages .mentor-message.assistant .mentor-message-body").last().boundingBox();
expect(assistantBody.width).toBeLessThanOrEqual(900);
await page.locator("#themeToggle").click();
const userMessage = page.locator("#mentorMessages .mentor-message.user");
const darkUserMessageStyle = await userMessage.evaluate((element) => ({
background: getComputedStyle(element).backgroundColor,
border: getComputedStyle(element).borderTopColor,
contentBackground: getComputedStyle(element.querySelector(".mentor-message-content")).backgroundColor,
contentColor: getComputedStyle(element.querySelector(".mentor-message-content")).color,
}));
expect(darkUserMessageStyle.background).not.toBe("rgb(255, 255, 255)");
expect(darkUserMessageStyle.border).not.toBe("rgb(255, 255, 255)");
expect(darkUserMessageStyle.contentBackground).toBe("rgb(53, 89, 140)");
expect(darkUserMessageStyle.contentColor).toBe("rgb(234, 241, 251)");
const darkMessageStyle = await answer.evaluate((element) => {
const style = getComputedStyle(element);
const content = element.querySelector(".mentor-message-content");
const heading = element.querySelector(".mentor-answer-heading");
const label = element.querySelector(".mentor-message-label");
return {
background: style.backgroundColor,
border: style.borderTopColor,
shadow: style.boxShadow,
contentColor: getComputedStyle(content).color,
headingColor: getComputedStyle(heading).color,
labelColor: getComputedStyle(label).color,
};
});
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.shadow).toBe("none");
expect(darkMessageStyle.contentColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.headingColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.labelColor).toBe("rgb(124, 130, 138)");
});
test("mentor avatars map per contact id and follow the final day/night palette", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
const mentors = [
{
id: "xiaobai-perspective", name: "小白", description: "个人复盘记录蒸馏", tagline: "复盘自己",
focus: ["个人复盘"], evidence: { grade: "A", label: "私有原始语料", note: "私人语料" }, quality: {}, private: true,
},
{
id: "kobe92-perspective", name: "52科比", description: "情绪周期心法", tagline: "先看周期",
focus: ["情绪周期"], evidence: { grade: "A", label: "心法文本", note: "心法" }, quality: {}, private: false,
},
{
id: "beijingchaojia-perspective", name: "北京炒家", description: "实盘记录", tagline: "实盘为先",
focus: ["实盘"], evidence: { grade: "A", label: "实盘资料", note: "实盘" }, quality: {}, private: false,
},
{
id: "chaojiyangjia-perspective", name: "炒股养家", description: "原始语料", tagline: "情绪为上",
focus: ["情绪"], evidence: { grade: "B", label: "原始语料", note: "语料" }, quality: {}, private: false,
},
];
await mockApplication(page, session("admin", true), { mentors });
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
const expectations = {
"xiaobai-perspective": ["mentor-avatar-tone-violet", "rgb(238, 236, 253)", "rgb(106, 92, 245)"],
"kobe92-perspective": ["mentor-avatar-tone-blue", "rgb(227, 240, 255)", "rgb(51, 112, 255)"],
"beijingchaojia-perspective": ["mentor-avatar-tone-green", "rgb(221, 245, 229)", "rgb(46, 164, 79)"],
"chaojiyangjia-perspective": ["mentor-avatar-tone-orange", "rgb(253, 238, 221)", "rgb(217, 122, 27)"],
};
for (const [id, [tone, background, ink]] of Object.entries(expectations)) {
const avatar = page.locator(`#mentorList [data-mentor-id="${id}"] .mentor-avatar`);
await expect(avatar).toHaveClass(new RegExp(tone));
const palette = await avatar.evaluate((element) => {
const style = getComputedStyle(element);
return { background: style.backgroundColor, color: style.color };
});
expect(palette.background).toBe(background);
expect(palette.color).toBe(ink);
}
await page.locator('[data-mentor-id="kobe92-perspective"]').click();
await expect(page.locator("#activeMentorAvatar")).toHaveClass(/mentor-avatar-tone-blue/);
await page.locator("#themeToggle").click();
const nightPalette = await page.locator('#mentorList [data-mentor-id="beijingchaojia-perspective"] .mentor-avatar')
.evaluate((element) => {
const style = getComputedStyle(element);
return { background: style.backgroundColor, color: style.color };
});
expect(nightPalette.background).toBe("rgb(35, 74, 56)");
expect(nightPalette.color).toBe("rgb(95, 206, 143)");
await expect(page.locator("#activeMentorAvatar")).toHaveClass(/mentor-avatar-tone-blue/);
});
test("mentor floating dialogs open centered, save notes per account, and keep composer one-line", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await page.locator('#mentorList [data-mentor-id="source-b"]').click();
await page.locator("#mentorNoteButton").click();
await expect(page.locator("#mentorNoteDialog")).toBeVisible();
const noteDialog = await page.locator("#mentorNoteDialog").boundingBox();
expect(noteDialog.width).toBeLessThanOrEqual(400);
expect(Math.abs(noteDialog.x + noteDialog.width / 2 - 720)).toBeLessThanOrEqual(3);
expect(Math.abs(noteDialog.y + noteDialog.height / 2 - 450)).toBeLessThanOrEqual(3);
await page.locator("#mentorNoteInput").fill("多源老师:确认后再行动");
await expect(page.locator("#mentorNoteInput")).toHaveValue("多源老师:确认后再行动");
await page.locator('[data-mentor-dialog-close="mentorNoteDialog"]').click();
await expect(page.locator("#mentorNoteDialog")).not.toBeVisible();
await page.locator("#mentorNoteButton").click();
await expect(page.locator("#mentorNoteInput")).toHaveValue("多源老师:确认后再行动");
await page.locator('[data-mentor-dialog-close="mentorNoteDialog"]').click();
await page.locator("#mentorProfileButton").click();
await expect(page.locator("#mentorProfileDialog")).toBeVisible();
await expect(page.locator("#mentorProfileDialogName")).toHaveText("多源老师");
await expect(page.locator("#mentorProfileDialogEvidence")).toHaveText("公开访谈与多源材料");
const profileDialog = await page.locator("#mentorProfileDialog").boundingBox();
expect(profileDialog.width).toBeLessThanOrEqual(400);
expect(Math.abs(profileDialog.x + profileDialog.width / 2 - 720)).toBeLessThanOrEqual(3);
await page.locator('[data-mentor-dialog-close="mentorProfileDialog"]').click();
await expect(page.locator("#mentorProfileDialog")).not.toBeVisible();
const disclaimerStyle = await page.locator("#mentorView .mentor-disclaimer").evaluate((el) => ({
align: getComputedStyle(el).textAlign,
}));
expect(disclaimerStyle.align).toBe("center");
const composerHint = page.locator("#mentorView .mentor-composer-hint");
await expect(composerHint).toHaveCount(1);
await expect(composerHint).toBeVisible();
const composerInput = page.locator("#mentorQuestion");
const oneLineHeight = (await composerInput.boundingBox()).height;
expect(oneLineHeight).toBeGreaterThanOrEqual(24);
expect(oneLineHeight).toBeLessThanOrEqual(34);
await composerInput.fill("第一行\n第二行");
const grownHeight = (await composerInput.boundingBox()).height;
expect(grownHeight).toBeGreaterThan(oneLineHeight + 8);
await composerInput.press("Shift+Enter");
await composerInput.type("第三行");
await composerInput.fill("只发送这一行");
await page.locator("#sendMentorQuestion").click();
await expect(page.locator("#mentorMessages .mentor-message.user").last()).toContainText("只发送这一行");
const resetHeight = (await composerInput.boundingBox()).height;
expect(resetHeight).toBeLessThanOrEqual(34);
});
test("global dialogs share the stage 18 geometry without changing account or admin access", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.keyboard.press("Control+K");
await expect(page.locator("#globalSearchDialog")).toHaveAttribute("aria-labelledby", "globalSearchTitle");
expect((await page.locator("#globalSearchDialog").boundingBox()).width).toBeLessThanOrEqual(662);
await page.locator("#closeGlobalSearch").click();
await page.evaluate(() => openStock("002141", { code: "002141", name: "贤丰控股", sector: "元件" }));
const stockGeometry = await page.locator("#stockDialog").evaluate((dialog) => ({
width: dialog.getBoundingClientRect().width,
height: dialog.getBoundingClientRect().height,
overflowY: getComputedStyle(dialog).overflowY,
headerPosition: getComputedStyle(dialog.querySelector(".dialog-header")).position,
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
}));
expect(stockGeometry.width).toBeLessThanOrEqual(812);
expect(stockGeometry.height).toBeLessThanOrEqual(878);
expect(stockGeometry.overflowY).toBe("auto");
expect(stockGeometry.headerPosition).toBe("sticky");
expect(stockGeometry.documentOverflow).toBeLessThanOrEqual(1);
await page.locator("#closeStockDialog").click();
await page.locator("#alertButton").click();
await expect(page.locator("#alertsDialog")).toHaveAttribute("aria-labelledby", "alertsDialogTitle");
expect((await page.locator("#alertsDialog").boundingBox()).width).toBeLessThanOrEqual(722);
await page.locator("#closeAlertsDialog").click();
await page.locator("#assistantButton").click();
const assistantGeometry = await page.locator("#assistantDialog").evaluate((dialog) => ({
height: dialog.getBoundingClientRect().height,
overflowY: getComputedStyle(dialog).overflowY,
contentDisplay: getComputedStyle(dialog.querySelector(".assistant-member-content")).display,
}));
expect(assistantGeometry.height).toBeLessThanOrEqual(762);
expect(assistantGeometry.overflowY).toBe("hidden");
expect(assistantGeometry.contentDisplay).toBe("flex");
await page.locator("#closeAssistantDialog").click();
await openHeaderCommandMenu(page);
await page.locator("#accountButton").click();
await page.locator('[data-account-panel="membership"]').click();
await expect(page.locator("#settingsDialog")).toHaveAttribute("aria-labelledby", "accountDialogTitle");
await expect(page.locator("#accountDialogTitle")).toHaveText("会员状态");
const accountBox = await page.locator("#settingsDialog").boundingBox();
expect(accountBox.width).toBeLessThanOrEqual(722);
expect(Math.abs(accountBox.x + accountBox.width / 2 - 720)).toBeLessThanOrEqual(2);
expect(Math.abs(accountBox.y + accountBox.height / 2 - 450)).toBeLessThanOrEqual(2);
await page.locator("#closeSettingsDialog").click();
await openHeaderCommandMenu(page);
await page.locator("#settingsButton").click();
await expect(page.locator("#adminDialog")).toHaveAttribute("aria-labelledby", "adminDialogTitle");
await expect(page.locator("#adminSectionSelect")).toBeVisible();
const adminBox = await page.locator("#adminDialog").boundingBox();
expect(adminBox.width).toBeLessThanOrEqual(902);
expect(Math.abs(adminBox.x + adminBox.width / 2 - 720)).toBeLessThanOrEqual(2);
expect(Math.abs(adminBox.y + adminBox.height / 2 - 450)).toBeLessThanOrEqual(2);
await page.locator("#closeAdminDialog").click();
await page.setViewportSize({ width: 375, height: 812 });
await page.locator("#headerMenuButton").click();
await page.locator('[data-mobile-command-target="alertButton"]').click();
const mobileGeometry = await page.locator("#alertsDialog").evaluate((dialog) => ({
width: dialog.getBoundingClientRect().width,
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
}));
expect(mobileGeometry.width).toBeLessThanOrEqual(375);
expect(mobileGeometry.documentOverflow).toBeLessThanOrEqual(1);
});
test("merged header keeps date, detail fields and 13 pool columns after dual-review rework", async ({ page }) => {
const previousLimits = dashboard.limits;
dashboard.limits = [{
code: "002141",
name: "贤丰控股",
streak: 5,
change: 10.02,
price: 12.48,
sector: "电子元件",
first_time: "09:31",
last_time: "10:18",
open_times: 1,
turnover_rate: 18.42,
amount_billion: 12.6,
seal_amount_million: 8200,
reason: "板块龙头连板打开空间",
}];
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
dashboard.limits = previousLimits;
await page.waitForTimeout(400);
await expect(page.locator("#currentPageSubtitle")).toContainText("市场复盘 ·");
await expect(page.locator("#headerMenuButton")).toBeHidden();
await expect(page.locator("#headerCommandGroup")).toBeVisible();
await expect(page.locator("#themeToggle")).toBeVisible();
await expect(page.locator("#refreshButton")).toBeVisible();
await page.locator('[data-view="limitPool"]').first().click();
await expect(page.locator("#currentPageSubtitle")).toContainText("市场复盘 ·");
await expect(page.locator("#currentPageSubtitle")).not.toHaveText("市场复盘");
await page.setViewportSize({ width: 1280, height: 800 });
await expect(page.locator(".tape-optional").first()).toBeHidden();
await page.locator("#overviewToggle").click();
const detailText = await page.locator(".tape-detail").innerText();
for (const label of ["市场情绪", "上涨家数", "下跌家数", "涨停", "跌停", "炸板", "封板率", "两市成交", "数据日期"]) {
expect(detailText).toContain(label);
}
expect(detailText).not.toContain("涨停 / 跌停");
await expect(page.locator("#tapeLimitDown")).toBeVisible();
await expect(page.locator("#detailBroken")).toBeVisible();
await expect(page.locator("#dataDateMetric")).toBeVisible();
await expect(page.locator(".tape-detail-date")).toBeVisible();
expect(await page.locator("#dataDateMetric").evaluate((node) => Boolean(node.closest(".metric-wide")))).toBe(false);
await page.keyboard.press("Escape");
const measurePoolTable = () => {
const wrap = document.querySelector("#limitPool .pool-table-card");
const table = document.querySelector("#limitTable");
const reason = document.querySelector("#limitTable .reason-column");
const reasonCell = document.querySelector("#limitTable .pool-reason-cell");
const headerCell = document.querySelector("#limitPool .data-table thead th");
const rowCell = document.querySelector("#limitPool .data-table tbody td");
const cells = [...document.querySelectorAll("#limitTable tbody td")];
const numberCells = [...document.querySelectorAll("#limitTable tbody td.num, #limitTable tbody td.number")];
const header = document.querySelector(".app-header");
const headerStyle = headerCell ? getComputedStyle(headerCell) : null;
const rowStyle = rowCell ? getComputedStyle(rowCell) : null;
const widths = cells.map((cell) => cell.getBoundingClientRect().width);
const numberWidths = numberCells.map((cell) => cell.getBoundingClientRect().width);
return {
tableLayout: table ? getComputedStyle(table).tableLayout : "",
columnCount: document.querySelectorAll("#limitTable thead th").length,
minCellWidth: widths.length ? Math.min(...widths) : 0,
minNumberWidth: numberWidths.length ? Math.min(...numberWidths) : 0,
wrapOverflow: wrap ? wrap.scrollWidth - wrap.clientWidth : 0,
tableWidth: table ? table.getBoundingClientRect().width : 0,
wrapWidth: wrap ? wrap.getBoundingClientRect().width : 0,
cardOverflow: wrap ? wrap.scrollWidth - wrap.clientWidth : 0,
reasonVisible: Boolean(reason && wrap && reason.getBoundingClientRect().right <= wrap.getBoundingClientRect().right + 1),
reasonText: reason ? reason.textContent.trim() : "",
reasonTitle: reasonCell?.getAttribute("title") || "",
headerOverflow: header ? header.scrollWidth - header.clientWidth : 0,
headerHeight: headerStyle ? Number.parseFloat(headerStyle.height) : 0,
headerFont: headerStyle ? Number.parseFloat(headerStyle.fontSize) : 0,
rowHeight: rowStyle ? Number.parseFloat(rowStyle.height) : 0,
};
};
const pool1280 = await page.evaluate(measurePoolTable);
expect(pool1280.columnCount).toBe(13);
expect(pool1280.tableLayout).toBe("auto");
expect(pool1280.minCellWidth).toBeGreaterThanOrEqual(40);
expect(pool1280.minNumberWidth).toBeGreaterThanOrEqual(50);
expect(pool1280.wrapOverflow).toBeGreaterThan(0);
expect(pool1280.reasonTitle).toContain("板块龙头连板打开空间");
expect(pool1280.headerHeight).toBe(36);
expect(pool1280.rowHeight).toBeGreaterThanOrEqual(40);
await page.setViewportSize({ width: 1600, height: 1000 });
await page.locator('[data-view="limitPool"]').first().click();
const tableFit = await page.evaluate(measurePoolTable);
expect(tableFit.reasonText).toContain("涨停原因");
expect(tableFit.reasonVisible).toBe(true);
expect(tableFit.cardOverflow).toBeLessThanOrEqual(1);
expect(tableFit.headerOverflow).toBeLessThanOrEqual(1);
expect(tableFit.reasonTitle).toContain("板块龙头连板打开空间");
expect(tableFit.headerHeight).toBe(36);
expect(tableFit.headerFont).toBe(12.5);
expect(tableFit.rowHeight).toBeGreaterThanOrEqual(40);
expect(tableFit.tableLayout).toBe("fixed");
expect(tableFit.columnCount).toBe(13);
await page.locator('[data-view="heavenView"]').first().click();
await expect(page.locator(".app-header .overview-strip")).toBeHidden();
await expect(page.locator("#overviewToggle")).toBeHidden();
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('[data-view="limitPool"]').first().click({ force: true });
const pool390 = await page.evaluate(measurePoolTable);
expect(pool390.columnCount).toBe(13);
expect(pool390.tableLayout).toBe("auto");
expect(pool390.minCellWidth).toBeGreaterThanOrEqual(40);
expect(pool390.minNumberWidth).toBeGreaterThanOrEqual(50);
expect(pool390.wrapOverflow).toBeGreaterThan(0);
expect(pool390.reasonTitle).toContain("板块龙头连板打开空间");
await page.locator("#mobileMarketViewSelect").selectOption("sentimentCycleView");
await page.locator("#overviewToggle").click();
const mobileDetail = await page.locator(".tape-detail").innerText();
for (const label of ["市场情绪", "上涨家数", "下跌家数", "涨停", "跌停", "炸板", "封板率", "两市成交", "数据日期"]) {
expect(mobileDetail).toContain(label);
}
await expect(page.locator("#dataDateMetric")).toBeVisible();
});
test("B-199 screener review and account surfaces fit day night viewports", async ({ page }) => {
const fs = require("node:fs");
const path = require("node:path");
const shotDir = path.join(__dirname, "../../runtime/b199-shots");
fs.mkdirSync(shotDir, { recursive: true });
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
const overflowX = () => page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
const shot = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`), fullPage: true });
await mockApplication(page, session("admin", true));
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/index.html");
await page.locator('[data-view="screenerView"]').first().click();
await expect(page.locator("#screenerView")).toHaveClass(/active-view/);
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("screener-1600-day");
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
await expect(page.locator("#reviewWorkspaceView")).toHaveClass(/active-view/);
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("review-1600-day");
await openHeaderCommandMenu(page);
await page.locator("#accountButton").click();
await page.locator('[data-account-panel="membership"]').click();
await expect(page.locator("#settingsDialog")).toBeVisible();
await shot("account-1600-day");
await page.locator("#closeSettingsDialog").click();
await openHeaderCommandMenu(page);
await page.locator("#settingsButton").click();
await expect(page.locator("#adminDialog")).toBeVisible();
await shot("admin-1600-day");
await page.locator("#closeAdminDialog").click();
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.locator('[data-view="screenerView"]').first().click();
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("screener-1600-night");
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("review-1600-night");
await page.setViewportSize({ width: 1280, height: 800 });
await page.locator('[data-view="screenerView"]').first().click();
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("screener-1280-night");
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("review-1280-night");
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await page.locator('[data-view="screenerView"]').first().click();
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("screener-1280-day");
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("review-1280-day");
await page.locator('[data-view="screenerView"]').first().click();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator("#screenerView")).toHaveClass(/active-view/);
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("screener-390-day");
await page.locator(".mobile-primary-tab[data-view=\"reviewWorkspaceView\"]").click();
await expect(page.locator("#reviewWorkspaceView")).toHaveClass(/active-view/);
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("review-390-day");
expect(pageErrors).toEqual([]);
});
test("desktop header keeps refresh, admin commands and account identity visible", async ({ page }) => {
const fs = require("node:fs");
const path = require("node:path");
const shotDir = path.join(__dirname, "../../runtime/b214-shots");
fs.mkdirSync(shotDir, { recursive: true });
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
const shot = (name) => page.locator(".app-header").screenshot({ path: path.join(shotDir, `${name}.png`) });
const shotPage = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`) });
const measureHeader = () => page.evaluate(() => {
const header = document.querySelector(".app-header");
const tape = document.querySelector(".market-tape");
const actions = document.querySelector(".header-actions");
const refresh = document.querySelector("#refreshButton");
const account = document.querySelector("#accountButton");
const name = document.querySelector("#accountName");
const box = (node) => node ? node.getBoundingClientRect() : null;
const visible = (node) => {
if (!node || node.hidden) return false;
const style = getComputedStyle(node);
if (style.display === "none" || style.visibility === "hidden") return false;
const rect = node.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const tapeBox = box(tape);
const actionsBox = box(actions);
return {
headerOverflow: header ? header.scrollWidth - header.clientWidth : 0,
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
tapeHeight: tapeBox ? tapeBox.height : 0,
overlap: tapeBox && actionsBox
? Math.max(0, Math.min(tapeBox.right, actionsBox.right) - Math.max(tapeBox.left, actionsBox.left))
* Math.max(0, Math.min(tapeBox.bottom, actionsBox.bottom) - Math.max(tapeBox.top, actionsBox.top))
: 0,
refreshVisible: visible(refresh),
accountVisible: visible(account),
nameVisible: visible(name),
nameFits: name ? name.clientWidth >= name.scrollWidth : false,
};
});
for (const viewport of [
{ width: 1600, height: 1000 },
{ width: 1440, height: 900 },
{ width: 1280, height: 800 },
]) {
await page.setViewportSize(viewport);
await expect(page.locator("#headerMenuButton")).toBeHidden();
await expect(page.locator("#headerCommandGroup")).toBeVisible();
await expect(page.locator("#refreshButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeVisible();
await expect(page.locator("#settingsButton")).toBeVisible();
await expect(page.locator("#accountAdminBadge")).toBeVisible();
await expect(page.locator("#accountVipBadge")).toBeVisible();
await expect(page.locator("#accountButton")).toBeVisible();
await expect(page.locator("#accountName")).toBeVisible();
const geometry = await measureHeader();
expect(geometry.headerOverflow, `${viewport.width} header overflow`).toBeLessThanOrEqual(1);
expect(geometry.documentOverflow, `${viewport.width} document overflow`).toBeLessThanOrEqual(1);
expect(geometry.overlap, `${viewport.width} tape overlap`).toBe(0);
expect(geometry.tapeHeight, `${viewport.width} tape height`).toBeGreaterThan(20);
expect(geometry.nameFits, `${viewport.width} account name truncated`).toBe(true);
await page.locator("#refreshButton").click();
await expect(page.locator("#loadingOverlay")).toBeHidden();
await page.locator("#settingsButton").click();
await expect(page.locator("#adminDialog")).toBeVisible();
await page.locator("#closeAdminDialog").click();
await page.locator("#accountButton").click();
await expect(page.locator("#accountDropdown")).toBeVisible();
await page.keyboard.press("Escape");
await shot(`${viewport.width}-day`);
}
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.setViewportSize({ width: 1600, height: 1000 });
await shot("1600-night");
await page.locator("#themeToggle").click();
await page.locator('[data-view="heavenView"]').first().click();
await expect(page.locator("#heavenView")).toHaveClass(/active-view/);
await expect(page.locator("#refreshButton")).toBeVisible();
await expect(page.locator("#accountButton")).toBeVisible();
await expect(page.locator("#headerMenuButton")).toBeHidden();
await shot("heaven-1600-day");
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator("#headerMenuButton")).toBeVisible();
await expect(page.locator("#refreshButton")).toBeHidden();
await page.locator("#headerMenuButton").click();
await expect(page.locator("#headerCommandGroup")).toBeVisible();
await expect(page.locator("#refreshButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeVisible();
await expect(page.locator("#settingsButton")).toBeVisible();
await expect(page.locator("#accountAdminBadge")).toBeVisible();
await expect(page.locator("#accountVipBadge")).toBeVisible();
await expect(page.locator("#accountButton")).toBeVisible();
await expect(page.locator('[data-mobile-command-target="themeToggle"]')).toBeVisible();
const mobileName = await page.evaluate(measureAccountName);
expect(mobileName.fits, "390 account name truncated").toBe(true);
expect(mobileName.stacked, "390 identity and account should stack").toBe(true);
await shotPage("390-menu-open");
await page.keyboard.press("Escape");
await expect(page.locator("#headerCommandGroup")).toBeHidden();
await mockApplication(page, session("user", false));
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/index.html");
await expect(page.locator("#refreshButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeHidden();
await expect(page.locator("#settingsButton")).toBeHidden();
await expect(page.locator("#accountAdminBadge")).toBeHidden();
await expect(page.locator("#accountVipBadge")).toBeVisible();
await expect(page.locator("#accountName")).toHaveText("normal_user");
await shot("1600-user-day");
expect(pageErrors).toEqual([]);
});
test("header username stays untruncated on desktop and in the mobile menu", async ({ page }) => {
test.setTimeout(120_000);
const fs = require("node:fs");
const path = require("node:path");
const shotDir = path.join(__dirname, "../../runtime/b217-shots");
fs.mkdirSync(shotDir, { recursive: true });
const measurements = [];
const viewports = [
{ width: 1600, height: 1000 },
{ width: 1440, height: 900 },
{ width: 1280, height: 800 },
{ width: 390, height: 844 },
];
const accounts = [
{ label: "admin", username: "review_admin", auth: namedSession("review_admin", "admin", true) },
{ label: "user", username: "normal_user", auth: namedSession("normal_user", "user", false) },
];
for (const account of accounts) {
await mockApplication(page, account.auth);
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/index.html?ui=desktop");
await expect(page.locator("#accountName")).toHaveText(account.username);
for (const theme of ["day", "night"]) {
await page.setViewportSize({ width: 1600, height: 1000 });
await setColorTheme(page, theme);
for (const viewport of viewports) {
await page.setViewportSize(viewport);
if (viewport.width === 390) {
await page.locator("#headerMenuButton").click();
await expect(page.locator("#headerCommandGroup")).toBeVisible();
} else {
await expect(page.locator("#headerMenuButton")).toBeHidden();
await expect(page.locator("#accountName")).toBeVisible();
}
const geometry = await page.evaluate(measureAccountName);
const label = `${account.label}-${viewport.width}-${theme}`;
expect(geometry.text, `${label} username`).toBe(account.username);
expect(geometry.fits, `${label} accountName truncated ${geometry.clientWidth}<${geometry.scrollWidth}`).toBe(true);
expect(geometry.documentOverflow, `${label} document overflow`).toBeLessThanOrEqual(1);
if (viewport.width === 390) {
expect(geometry.stacked, `${label} identity/account not stacked`).toBe(true);
}
if (viewport.width >= 1440 && account.label === "admin") {
expect(geometry.adminLabelVisible, `${label} admin badge text hidden`).toBe(true);
expect(geometry.vipLabelVisible, `${label} vip badge text hidden`).toBe(true);
}
measurements.push({
account: account.username,
theme,
width: viewport.width,
height: viewport.height,
...geometry,
});
const shotName = `${account.label}-${viewport.width}-${theme}.png`;
if (viewport.width === 390) {
await page.screenshot({ path: path.join(shotDir, shotName) });
await page.keyboard.press("Escape");
await expect(page.locator("#headerCommandGroup")).toBeHidden();
} else {
await page.locator(".app-header").screenshot({ path: path.join(shotDir, shotName) });
}
}
}
}
fs.writeFileSync(path.join(shotDir, "measurements.json"), `${JSON.stringify(measurements, null, 2)}\n`);
});
function measureHeaderProbe() {
const name = document.querySelector("#accountName");
const group = document.querySelector("#headerCommandGroup");
const strip = document.querySelector(".app-header .overview-strip");
const stripBox = strip ? strip.getBoundingClientRect() : null;
const stripShown = Boolean(stripBox && stripBox.width > 0 && stripBox.height > 0);
const tapeClip = stripShown
? [...strip.querySelectorAll("*")].filter((el) => (
el.children.length === 0 && el.scrollWidth > el.clientWidth + 1
)).map((el) => (el.textContent || "").trim()).filter(Boolean)
: [];
const groupBox = group ? group.getBoundingClientRect() : null;
const nameBox = name ? name.getBoundingClientRect() : null;
return {
groupRight: groupBox ? groupBox.right : 0,
nameRight: nameBox ? nameBox.right : 0,
nameFits: name ? name.clientWidth >= name.scrollWidth : false,
nameText: name ? String(name.textContent || "") : "",
tapeClip,
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
viewport: window.innerWidth,
};
}
async function applyColorTheme(page, theme) {
const wanted = theme === "night" ? "dark" : "light";
await page.evaluate((value) => {
document.documentElement.dataset.theme = value;
document.documentElement.style.colorScheme = value;
try { localStorage.setItem("xiaobaiTheme", value); } catch (_error) {}
}, wanted);
await expect(page.locator("html")).toHaveAttribute("data-theme", wanted);
}
async function probeHeaderWorkspaces(page, { account, theme, viewport, views, measurements }) {
await page.setViewportSize(viewport);
await applyColorTheme(page, theme);
for (const view of views) {
await page.locator(`[data-view="${view}"]`).first().click();
await expect(page.locator(`#${view}`)).toHaveClass(/active-view/);
const geometry = await page.evaluate(measureHeaderProbe);
const label = `${account.label}-${view}-${viewport.width}-${theme}`;
expect(geometry.nameText, `${label} username`).toBe(account.username);
expect(geometry.nameFits, `${label} accountName truncated`).toBe(true);
expect(geometry.groupRight, `${label} command group overflow ${geometry.groupRight}>${geometry.viewport}`).toBeLessThanOrEqual(geometry.viewport + 0.5);
expect(geometry.nameRight, `${label} accountName overflow ${geometry.nameRight}>${geometry.viewport}`).toBeLessThanOrEqual(geometry.viewport + 0.5);
expect(geometry.documentOverflow, `${label} document overflow`).toBeLessThanOrEqual(1);
expect(geometry.tapeClip, `${label} tape clipped ${geometry.tapeClip.join("|")}`).toEqual([]);
measurements.push({ account: account.username, view, theme, width: viewport.width, ...geometry });
}
}
test("desktop header keeps commands in view and tape text unclipped across workspaces", async ({ page }) => {
test.setTimeout(120_000);
const fs = require("node:fs");
const path = require("node:path");
const shotDir = path.join(__dirname, "../../runtime/b221-shots");
fs.mkdirSync(shotDir, { recursive: true });
const measurements = [];
const views = [
"sentimentCycleView",
"auctionView",
"themeLibraryView",
"popularityView",
"rotationView",
"mentorView",
"screenerView",
"reviewWorkspaceView",
"heavenView",
];
const viewports = [
{ width: 1600, height: 1000 },
{ width: 1440, height: 900 },
{ width: 1280, height: 800 },
];
const accounts = [
{ label: "admin", username: "review_admin", auth: namedSession("review_admin", "admin", true) },
{ label: "user", username: "normal_user", auth: namedSession("normal_user", "user", false) },
];
for (const account of accounts) {
await mockApplication(page, account.auth);
await page.goto("/index.html");
await expect(page.locator("#accountName")).toHaveText(account.username);
for (const viewport of viewports) {
await probeHeaderWorkspaces(page, { account, theme: "day", viewport, views, measurements });
await page.locator('[data-view="themeLibraryView"]').first().click();
await page.locator(".app-header").screenshot({
path: path.join(shotDir, `${account.label}-${viewport.width}-day.png`),
});
}
await probeHeaderWorkspaces(page, {
account,
theme: "night",
viewport: { width: 1600, height: 1000 },
views,
measurements,
});
await page.locator('[data-view="sentimentCycleView"]').first().click();
await page.locator(".app-header").screenshot({
path: path.join(shotDir, `${account.label}-1600-night.png`),
});
}
await mockApplication(page, accounts[0].auth);
await page.goto("/index.html");
await page.setViewportSize({ width: 390, height: 844 });
await applyColorTheme(page, "day");
await page.locator("#headerMenuButton").click();
await expect(page.locator("#headerCommandGroup")).toBeVisible();
const mobileDay = await page.evaluate(measureAccountName);
expect(mobileDay.fits, "390 day account name truncated").toBe(true);
expect(mobileDay.stacked, "390 day identity and account should stack").toBe(true);
await page.screenshot({ path: path.join(shotDir, "admin-390-day.png") });
await page.keyboard.press("Escape");
await applyColorTheme(page, "night");
await page.locator("#headerMenuButton").click();
const mobileNight = await page.evaluate(measureAccountName);
expect(mobileNight.fits, "390 night account name truncated").toBe(true);
expect(mobileNight.stacked, "390 night identity and account should stack").toBe(true);
await page.screenshot({ path: path.join(shotDir, "admin-390-night.png") });
fs.writeFileSync(path.join(shotDir, "measurements.json"), `${JSON.stringify(measurements, null, 2)}\n`);
});