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

2274 lines
128 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 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 = {}) {
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") payload = dashboard;
else if (url.pathname === "/api/stock/002141/preview") {
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") {
payload = {
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") {
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/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.latestScreenerResults) {
payload.latest_results = options.latestScreenerResults;
payload.latest_result = options.latestScreenerResults.smart || null;
}
} else if (url.pathname === "/api/screener/run") {
const body = route.request().postDataJSON();
options.screenerRunBodies = [...(options.screenerRunBodies || []), body];
payload = {
result: 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,
},
};
} 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: 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: "" }),
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) });
});
}
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 expect(page.locator("#settingsButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeVisible();
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",
"mentorView", "heavenView", "reviewWorkspaceView",
];
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("collapsed overview and sentiment decision 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("#sentimentStageGuideTitle")).toHaveText("判定口径");
await expect(page.locator('[data-sentiment-stage]:visible')).toHaveCount(1);
await expect(page.locator('[data-sentiment-stage="退潮"]')).toBeVisible();
await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。");
const alignment = await page.evaluate(() => {
const guide = document.querySelector(".sentiment-stage-guide").getBoundingClientRect();
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 currentGuide = document.querySelector("[data-sentiment-stage].current");
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 rangeHeader = document.querySelector(".sentiment-stage-guide-head > span:nth-child(3)").getBoundingClientRect();
const rangeValue = currentGuide.querySelector(".stage-range").getBoundingClientRect();
const labelStyle = getComputedStyle(document.querySelector(".sentiment-block .metric-label"));
const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text"));
return {
mainAligned: Math.abs(guide.x - trend.x) < 1 && Math.abs(guide.width - trend.width) < 1 && guide.top > trend.bottom,
railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom,
detailVisible: detail.top < innerHeight,
chartHeight: chart.height,
guideRowHeight: currentGuide.getBoundingClientRect().height,
guideIsWhite: getComputedStyle(currentGuide).backgroundColor === "rgb(255, 255, 255)",
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",
rangeAligned: Math.abs(rangeHeader.right - rangeValue.right) < 1,
};
});
expect(alignment.mainAligned).toBe(true);
expect(alignment.railAligned).toBe(true);
expect(alignment.detailVisible).toBe(true);
expect(alignment.chartHeight).toBeGreaterThanOrEqual(340);
expect(alignment.guideRowHeight).toBeLessThanOrEqual(52);
expect(alignment.guideIsWhite).toBe(true);
expect(alignment.sameType).toBe(true);
expect(alignment.sameBaseline).toBe(true);
expect(alignment.noStatusOffset).toBe(true);
expect(alignment.rangeAligned).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("昨日6板 → 今日");
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 只");
});
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();
renderRotationTable(state.dashboard.sector_rotation, state.dashboard.sectors);
});
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("#rotationTableBody tr").first()).toHaveClass(/selected/);
await page.locator("#rotationTracker .rotation-track-cancel").click();
await expect(page.locator("#rotationTracker")).toBeHidden();
await expect(page.locator("#rotationTableBody .trend-new")).toContainText("新进");
await page.locator('#rotationTable th[title^="变化"]').click();
await expect(page.locator("#rotationTableBody tr").first()).toContainText("-7");
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");
expect(await page.locator("#themeDetailChart").evaluate((canvas) => canvas.toDataURL().length)).toBeGreaterThan(100);
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();
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 daily = document.querySelector("#dragonDailyContent");
const operations = document.querySelector("#dragonTraderDetail .trader-operations");
return {
dailyOverflow: getComputedStyle(daily).overflowY,
dailyFits: daily.scrollHeight <= daily.clientHeight + 1,
operationOverflow: getComputedStyle(operations).overflowY,
operationsScroll: operations.scrollHeight > operations.clientHeight,
descriptionSize: parseFloat(getComputedStyle(document.querySelector("#dragonTraderDetail .dragon-detail-header p")).fontSize),
};
});
expect(scrollOwnership).toEqual({
dailyOverflow: "hidden",
dailyFits: true,
operationOverflow: "auto",
operationsScroll: true,
descriptionSize: 13,
});
await page.setViewportSize({ width: 390, height: 844 });
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("#screenerRunButton")).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("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 = CHART_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.evaluate(() => startHeartBreathing());
const timing = await page.evaluate(() => ({
remaining: state.heartBreathingEndsAt - Date.now(),
incenseDuration: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration,
incenseDelay: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDelay,
rippleDuration: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDuration,
rippleDelay: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDelay,
rippleAnimation: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationName,
rippleKeyframes: document.querySelector(".heart-breath-ripple span").getAnimations()[0].effect.getKeyframes(),
}));
expect(timing.remaining).toBeGreaterThan(45_000);
expect(timing.remaining).toBeLessThanOrEqual(46_000);
expect(timing.incenseDuration).toBe("45s");
expect(timing.incenseDelay).toBe("1s");
expect(timing.rippleDuration).toBe("9s");
expect(timing.rippleDelay).toBe("1s");
expect(timing.rippleAnimation).toContain("heart-breath-ripple");
expect(timing.rippleKeyframes.at(-1).transform).toContain("0.62");
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 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("呼");
});
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");
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator("#globalSearchButton")).toBeVisible();
});
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("#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: "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");
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")).toBeEnabled();
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: "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("#curatedDetailDialog")).toBeHidden();
const secondStrategy = page.locator('#curatedStrategyList [data-curated-strategy="4"]');
await secondStrategy.locator(".curated-card-description").click();
await expect(secondStrategy).toHaveClass(/active/);
await expect(page.locator('#curatedStrategyList [data-curated-strategy="2"]')).not.toHaveClass(/active/);
await secondStrategy.locator('[data-curated-inspect="4"]').click();
await expect(page.locator("#curatedDetailDialog")).toBeVisible();
const dialogBox = await page.locator("#curatedDetailDialog").boundingBox();
expect(Math.abs(dialogBox.x + dialogBox.width / 2 - 720)).toBeLessThanOrEqual(2);
expect(Math.abs(dialogBox.y + dialogBox.height / 2 - 450)).toBeLessThanOrEqual(2);
await page.locator("#closeCuratedDetailButton").click();
await page.screenshot({ path: "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(395);
expect(builderBox.width).toBeLessThanOrEqual(405);
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: "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();
});
test("screener stage completion follows its execution context 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 page.locator('[data-regime="retreat"]').click();
await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(2);
await expect(page.locator("#screenerRunStatus")).toHaveText("等待执行");
await expect(page.locator("#backtestTaskStatus")).toHaveText("随选股执行");
await expect(page.locator("#screenerEmpty")).toContainText("当前阶段与策略");
await expect(page.locator("#screenerResultSource")).toBeHidden();
await page.locator('[data-regime="repair"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
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 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: "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");
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 .heaven-toolbar h2").evaluate((element) => getComputedStyle(element).fontSize));
expect(titleSize).toBeLessThanOrEqual(22);
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(920);
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");
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();
expect(heartControls.width).toBeLessThanOrEqual(80);
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-page-header .mentor-evidence-filters")).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')).toHaveText("5/6");
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('[data-mentor-grade="B"]').click();
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("#activeMentorBadges")).toContainText("B · 多源整理");
await expect(page.locator("#activeMentorEvidence")).toHaveText("公开访谈与多源材料");
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();
expect(Math.abs(mentorLibrary.width - 340)).toBeLessThanOrEqual(1);
expect(mentorChat.x - (mentorLibrary.x + mentorLibrary.width)).toBeGreaterThanOrEqual(11);
expect(mentorInput.height).toBeLessThanOrEqual(40);
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 statusBar = await page.locator(".status-bar").boundingBox();
const lowerGap = statusBar.y - (expandedMentorLayout.y + expandedMentorLayout.height);
expect(lowerGap).toBeGreaterThanOrEqual(0);
expect(lowerGap).toBeLessThanOrEqual(8);
await page.locator("#overviewToggle").click();
const openOverviewLayout = await page.locator("#mentorView .mentor-layout").boundingBox();
const openOverviewGap = statusBar.y - (openOverviewLayout.y + openOverviewLayout.height);
expect(openOverviewGap).toBeGreaterThanOrEqual(0);
expect(openOverviewGap).toBeLessThanOrEqual(8);
expect(await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)).toBeLessThanOrEqual(1);
});
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-pin="source-c"]').click();
await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-c");
await page.locator('[data-mentor-pin="source-b"]').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);
});
test("mobile mentor directory opens as a searchable selector and hides private mentors", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await expect(page.locator("#mentorDirectoryToggle")).toBeVisible();
await page.locator("#mentorDirectoryToggle").click();
await expect(page.locator("#mentorView .mentor-sidebar")).toHaveClass(/is-open/);
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(21);
await expect(page.locator('#mentorList [data-mentor-id="private-owner"]')).toHaveCount(0);
await page.locator('#mentorList [data-mentor-id="source-c"]').click();
await expect(page.locator("#mentorView .mentor-sidebar")).not.toHaveClass(/is-open/);
await expect(page.locator("#mobileActiveMentorName")).toHaveText("推演老师");
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
});
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 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 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("#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);
});