2462 lines
138 KiB
JavaScript
2462 lines
138 KiB
JavaScript
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") {
|
||
options.dashboardRequests = (options.dashboardRequests || 0) + 1;
|
||
if (options.dashboardDelay) {
|
||
await new Promise((resolve) => setTimeout(resolve, options.dashboardDelay));
|
||
}
|
||
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/profiles") {
|
||
payload = {
|
||
meta: { status: "success", source: "tushare", cached: true },
|
||
summary: { profile_count: 3, described_count: 2, organization_count: 4 },
|
||
profiles: [
|
||
{ id: "hot-money-profile-1", name: "赵老哥", description: "聚焦市场核心标的。", organizations: ["华泰证券浙江分公司", "银河证券绍兴"], organization_count: 2 },
|
||
{ id: "hot-money-profile-2", name: "炒股养家", description: "重视情绪与风险收益比。", organizations: ["华鑫证券上海宛平南路"], organization_count: 1 },
|
||
{ id: "hot-money-profile-3", name: "作手新一", description: "", organizations: ["国泰海通证券南京太平南路"], organization_count: 1 },
|
||
],
|
||
};
|
||
}
|
||
else if (url.pathname === "/api/dragon-tiger") {
|
||
payload = {
|
||
meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "empty", source: "tushare" },
|
||
summary: {},
|
||
traders: [],
|
||
unclassified_seats: [],
|
||
};
|
||
} else if (url.pathname === "/api/sentiment/history") payload = { rows: [], components: [] };
|
||
else if (url.pathname === "/api/rotation/history") payload = { days: [], rows: [], sectors: [] };
|
||
else if (url.pathname === "/api/auction") {
|
||
payload = {
|
||
meta: { trade_date: "2026-07-22", carried_forward: false, phase: "finalized", available: true, actionable: false },
|
||
summary: { stock_count: 3, candidate_count: 1, focus_count: 1, one_price_count: 1, up_count: 2, down_count: 1, limit_open_count: 1, strong_open_count: 2, median_change: 1.2, amount_billion: 2.5, amount_change_previous: 12.5, amount_change_5d: 8.2 },
|
||
expectations: { "超预期": 1, "符合预期": 0, "低于预期": 0 },
|
||
candidate_meta: { baseline_date: "2026-07-21" },
|
||
themes: {
|
||
carry: [{ name: "Test Sector", status: "强承接", prior_limit_count: 2, leader: "Test Stock", matched_count: 1, median_change: 4.2, positive_rate: 100, amount_million: 15 }],
|
||
new_themes: [{ name: "人工智能", stock_count: 2, median_change: 3.5, amount_million: 26, leaders: ["Test Stock"] }],
|
||
},
|
||
amount_history: [
|
||
{ trade_date: "2026-07-21", amount_billion: 2.2, stock_count: 2 },
|
||
{ trade_date: "2026-07-22", amount_billion: 2.5, stock_count: 2 },
|
||
],
|
||
news_feedback: { available: false, message: "隔夜消息反馈暂不可用", detail: "待稳定的新闻与公告数据接入后开放" },
|
||
focus_rows: [
|
||
{ code: "002141", name: "Test Stock", sector: "Test Sector", change: 4.2, price: 10.2, amount_million: 15, volume_ratio: 1.8, turnover_rate: 0.12, source_label: "昨日涨停 · 同花顺热榜", expectation: "超预期", expected_change: 2.2, attention_score: 88.5, core_tags: ["人气前5"], expectation_reason: "昨日首板;竞价涨幅高于预期中枢2.0个百分点,量比1.80" },
|
||
],
|
||
one_price_rows: [
|
||
{ code: "000001", name: "Limit Stock", sector: "Test Sector", change: 10, price: 11, amount_million: 8, volume_ratio: 3.2, source_label: "昨日涨停", prior_streak: 3, core_tags: ["三板以上"], is_market_core: true, is_one_price: true },
|
||
],
|
||
watchlist_rows: [
|
||
{ code: "000002", name: "Watch Stock", sector: "Bank", change: -1.2, price: 9.88, amount_million: 3, volume_ratio: 0.9, expectation: "符合预期", expected_change: 0, attention_score: 32.5, core_tags: [], expectation_reason: "自选观察;竞价反馈接近个人观察基准", is_watchlist: true, available: true },
|
||
],
|
||
watchlist_missing_count: 0,
|
||
rows: [
|
||
{ code: "002141", name: "Test Stock", sector: "Test Sector", change: 4.2, price: 10.2, amount_million: 15, volume_ratio: 1.8, turnover_rate: 0.12, source_label: "昨日涨停 · 同花顺热榜", expectation: "超预期", expected_change: 2.2, attention_score: 88.5, core_tags: ["人气前5"], expectation_reason: "昨日首板;竞价涨幅高于预期中枢2.0个百分点,量比1.80" },
|
||
],
|
||
};
|
||
} else if (url.pathname === "/api/themes") {
|
||
payload = {
|
||
meta: { trade_date: "2026-07-22", carried_forward: false },
|
||
summary: { theme_count: 1, quoted_count: 1, up_count: 1, down_count: 0, hot_count: 1 },
|
||
items: [{ code: "885728.TI", name: "人工智能", member_count: 1, change: 2.2, turnover_rate: 3.1, hot_rank: 1, has_quote: true }],
|
||
};
|
||
} else if (url.pathname === "/api/themes/detail") {
|
||
payload = {
|
||
meta: { trade_date: "2026-07-22" },
|
||
theme: { code: "885728.TI", name: "人工智能", member_count: 1, change: 2.2, turnover_rate: 3.1 },
|
||
summary: { member_count: 1, quoted_count: 1, up_count: 1, down_count: 0 },
|
||
series: [
|
||
{ trade_date: "2026-07-21", open: 100, high: 104, low: 99, close: 103, change: 3, volume: 1000 },
|
||
{ trade_date: "2026-07-22", open: 103, high: 106, low: 102, close: 105, change: 1.94, volume: 1200 },
|
||
],
|
||
members: [{ code: "002141", name: "Test Stock", change: 2.4, price: 10.8, amount_billion: 3.2, has_quote: true }],
|
||
};
|
||
} else if (url.pathname === "/api/popularity") {
|
||
const hot = { rank: 1, code: "002141", ts_code: "002141.SZ", name: "Test Stock", change: 2.4, price: 10.8, ths_rank: 1, dc_rank: 2, rank_change: 3, concepts: ["人工智能"], dual_source: true };
|
||
payload = { meta: { trade_date: "2026-07-22", carried_forward: false }, summary: { ths_count: 1, dc_count: 1, dual_count: 1 }, combined: [hot], ths: [{ ...hot, rank: 1 }], dc: [{ ...hot, rank: 2 }] };
|
||
}
|
||
else if (url.pathname === "/api/screener/setup") {
|
||
options.screenerSetupRequests = (options.screenerSetupRequests || 0) + 1;
|
||
const factorFields = [
|
||
["amount_billion", "成交额(亿元)"], ["above_ma20", "站上20日线"],
|
||
["relative_strength", "相对强度"], ["sector_strength", "板块强度"],
|
||
["volume_ratio_5d", "5日量比"], ["volatility_10d", "10日波动率"],
|
||
["dividend_yield_ttm", "股息率TTM"],
|
||
].map(([id, label]) => ({ id, label }));
|
||
payload = {
|
||
trade_date: "20260722",
|
||
regime: { id: "repair", label: "修复", confidence: 70, reason: "测试", evidence: [] },
|
||
regimes: [{ id: "repair", label: "修复" }],
|
||
factor_fields: factorFields,
|
||
factor_groups: [{ name: "行情与质量", fields: factorFields }],
|
||
operators: [">", ">=", "<", "<=", "==", "between"],
|
||
factor_data: {
|
||
ready: true,
|
||
date_count: 45,
|
||
start_date: "20260518",
|
||
end_date: "20260722",
|
||
health: { market: true, auction: true, valuation: true, fundamental: true, dividend_history: true },
|
||
},
|
||
llm: { configured: true },
|
||
strategies: [
|
||
{
|
||
id: 1, name: "修复确认", description: "保留原有智能策略流程", regimes: ["repair"], builtin: true,
|
||
data_ready: true, missing_data: [],
|
||
formula: {
|
||
meta: { library: "smart" }, universe: { exclude_st: true, listed_days_min: 120 }, filters: [],
|
||
score: [{ field: "relative_strength", weight: 1, direction: "desc" }], limit: 15, min_score: 0.5,
|
||
},
|
||
},
|
||
{
|
||
id: 2, name: "连续分红质量", description: "持续分红、估值与流动性共同约束。", regimes: ["repair"], builtin: true,
|
||
data_ready: true, missing_data: [],
|
||
formula: {
|
||
meta: { library: "curated", category: "红利价值", quality: "A", frequency: "月度", risk: "中低", data_group: "估值与财务" },
|
||
universe: { exclude_st: true, listed_days_min: 720 },
|
||
filters: [{ field: "dividend_yield_ttm", op: ">=", value: 2 }],
|
||
score: [{ field: "dividend_yield_ttm", weight: 1, direction: "desc" }], limit: 20, min_score: 0.5,
|
||
},
|
||
},
|
||
],
|
||
};
|
||
if (options.additionalScreenerRegimes) {
|
||
payload.regimes.push(...options.additionalScreenerRegimes);
|
||
}
|
||
if (options.additionalScreenerStrategies) {
|
||
payload.strategies.push(...options.additionalScreenerStrategies);
|
||
}
|
||
if (options.latestScreenerResults) {
|
||
payload.latest_results = options.latestScreenerResults;
|
||
payload.latest_result = options.latestScreenerResults.smart || null;
|
||
}
|
||
if (options.recentScreenerResults) {
|
||
payload.recent_results = options.recentScreenerResults;
|
||
}
|
||
} else if (url.pathname === "/api/screener/run") {
|
||
const body = route.request().postDataJSON();
|
||
options.screenerRunBodies = [...(options.screenerRunBodies || []), body];
|
||
const runResult = options.screenerRunResult?.(body);
|
||
payload = {
|
||
result: runResult || options.latestScreenerResults?.[body.mode] || {
|
||
meta: {
|
||
run_id: 99,
|
||
trade_date: "20260722",
|
||
regime: body.regime,
|
||
strategy_name: body.strategy_name,
|
||
mode: body.mode,
|
||
},
|
||
candidates: [],
|
||
disclaimer: "历史统计不代表未来收益",
|
||
backtest: null,
|
||
},
|
||
};
|
||
if (options.recentScreenerResults) {
|
||
options.recentScreenerResults.unshift(payload.result);
|
||
}
|
||
} else if (url.pathname === "/api/screener/tracking") {
|
||
if (route.request().method() === "POST") {
|
||
const body = route.request().postDataJSON();
|
||
screenerTracking = {
|
||
batches: [...screenerTracking.batches, {
|
||
run_id: body.run_id,
|
||
selection_date: "20260722",
|
||
strategy_name: "Manual strategy",
|
||
items: [{ id: 10, code: body.code, name: "Manual Stock", entry_price: 12, observed_days: 0, status: "等待 T+1" }],
|
||
}],
|
||
summary: { ...screenerTracking.summary, total: screenerTracking.summary.total + 1 },
|
||
};
|
||
payload = { ok: true, tracking: screenerTracking };
|
||
} else payload = screenerTracking;
|
||
} else if (/^\/api\/screener\/tracking\/\d+$/.test(url.pathname)) {
|
||
const trackId = Number(url.pathname.split("/").at(-1));
|
||
screenerTracking = {
|
||
batches: screenerTracking.batches.map((batch) => ({
|
||
...batch,
|
||
items: batch.items.filter((item) => item.id !== trackId),
|
||
})).filter((batch) => batch.items.length),
|
||
summary: { ...screenerTracking.summary, total: Math.max(0, screenerTracking.summary.total - 1) },
|
||
};
|
||
payload = { ok: true, deleted: true, tracking: screenerTracking };
|
||
} else if (url.pathname === "/api/heaven/readings") {
|
||
payload = {
|
||
mode: url.searchParams.get("mode") || "fortune",
|
||
items: [{
|
||
id: 31,
|
||
mode: "fortune",
|
||
context_date: "20260723",
|
||
subject: "2026-07-23 观气",
|
||
subject_detail: "丙午年 · 乙未月 · 己丑日 · 土气偏显",
|
||
answer: "三层气机已经合参,今日宜先定节奏,再看行动。",
|
||
created_at: "2026-07-23T09:12:00+08:00",
|
||
}],
|
||
};
|
||
} else if (url.pathname === "/api/mentors/setup") {
|
||
payload = { trade_date: "20260722", mentors: 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("manual refresh stays in place without reopening the full-page loader", async ({ page }) => {
|
||
const options = { dashboardDelay: 350 };
|
||
await mockApplication(page, session(), options);
|
||
await page.goto("/index.html");
|
||
await expect(page.locator("#loadingOverlay")).toBeHidden();
|
||
|
||
await page.locator("#refreshButton").click();
|
||
|
||
await expect(page.locator("#refreshButton")).toBeDisabled();
|
||
await expect(page.locator("#loadingOverlay")).toBeHidden();
|
||
await expect(page.locator("#statusText")).toContainText("刷新");
|
||
await expect(page.locator("#refreshButton")).toBeEnabled();
|
||
expect(options.dashboardRequests).toBe(2);
|
||
});
|
||
|
||
test("night mode covers the application shell and persists across reloads", async ({ page }) => {
|
||
await mockApplication(page, session("admin", true));
|
||
await page.addInitScript(() => {
|
||
if (sessionStorage.getItem("themeTestReady")) return;
|
||
localStorage.removeItem("xiaobaiTheme");
|
||
sessionStorage.setItem("themeTestReady", "1");
|
||
});
|
||
await page.goto("/index.html");
|
||
|
||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "false");
|
||
await page.locator("#themeToggle").click();
|
||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到日间模式");
|
||
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "true");
|
||
|
||
const darkSurfaces = await page.evaluate(() => {
|
||
const color = (selector) => getComputedStyle(document.querySelector(selector)).backgroundColor;
|
||
return {
|
||
body: color("body"),
|
||
sidebar: color(".sidebar"),
|
||
topbar: color(".topbar"),
|
||
tableHead: color("#limitTable thead th"),
|
||
};
|
||
});
|
||
expect(new Set(Object.values(darkSurfaces)).has("rgb(255, 255, 255)")).toBe(false);
|
||
|
||
await page.reload();
|
||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "true");
|
||
await page.keyboard.press("Control+K");
|
||
await expect(page.locator("#globalSearchDialog")).toBeVisible();
|
||
expect(await page.locator("#globalSearchDialog").evaluate((dialog) => getComputedStyle(dialog).backgroundColor)).not.toBe("rgb(255, 255, 255)");
|
||
await page.locator("#closeGlobalSearch").click();
|
||
|
||
await page.locator("#themeToggle").click();
|
||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到夜间模式");
|
||
});
|
||
|
||
test("collapsed overview and sentiment 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.locator("#dragonProfilesButton").click();
|
||
await expect(page.locator("#dragonProfilesContent")).toBeVisible();
|
||
await expect(page.locator("#dragonDailyContent")).toBeHidden();
|
||
await expect(page.locator("#hotMoneyProfileSummary > span")).toHaveCount(3);
|
||
await expect(page.locator("#hotMoneyProfileList .hot-money-profile-row-v2")).toHaveCount(3);
|
||
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("赵老哥");
|
||
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("华泰证券浙江分公司");
|
||
await page.locator("#hotMoneyProfileSearch").fill("宛平南路");
|
||
await expect(page.locator("#hotMoneyProfileList .hot-money-profile-row-v2")).toHaveCount(1);
|
||
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("炒股养家");
|
||
await page.locator("#hotMoneyProfileSearch").fill("");
|
||
await page.locator('[data-hot-money-profile="hot-money-profile-3"]').click();
|
||
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("名录暂未收录该游资的公开简介");
|
||
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
const profileMobile = await page.evaluate(() => {
|
||
const list = document.querySelector("#hotMoneyProfileList").getBoundingClientRect();
|
||
const detail = document.querySelector("#hotMoneyProfileDetail").getBoundingClientRect();
|
||
return {
|
||
pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1,
|
||
detailBelowList: detail.top >= list.bottom - 1,
|
||
};
|
||
});
|
||
expect(profileMobile).toEqual({ pageFits: true, detailBelowList: true });
|
||
await page.locator("#dragonDailyButton").click();
|
||
|
||
const mobile = await page.evaluate(() => ({
|
||
pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1,
|
||
operationsScroll: document.querySelector("#dragonTraderDetail .trader-operations").scrollWidth > document.querySelector("#dragonTraderDetail .trader-operations").clientWidth + 1,
|
||
}));
|
||
expect(mobile).toEqual({ pageFits: true, operationsScroll: true });
|
||
});
|
||
|
||
test("auction owns a single vertical scroll container across desktop densities", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||
await mockApplication(page, session("admin", true));
|
||
await page.goto("/index.html");
|
||
await page.locator('[data-view="auctionView"]').first().click();
|
||
await page.evaluate(() => {
|
||
const body = document.querySelector("#auctionTableBody");
|
||
const seed = body.querySelector("tr");
|
||
if (!seed) return;
|
||
for (let index = 0; index < 28; index += 1) body.appendChild(seed.cloneNode(true));
|
||
});
|
||
|
||
const measure = () => page.evaluate(() => {
|
||
const box = (selector) => {
|
||
const element = document.querySelector(selector);
|
||
const style = getComputedStyle(element);
|
||
const rect = element.getBoundingClientRect();
|
||
return {
|
||
clientHeight: element.clientHeight,
|
||
scrollHeight: element.scrollHeight,
|
||
clientWidth: element.clientWidth,
|
||
scrollWidth: element.scrollWidth,
|
||
overflowY: style.overflowY,
|
||
top: rect.top,
|
||
bottom: rect.bottom,
|
||
width: rect.width,
|
||
};
|
||
};
|
||
return {
|
||
viewportHeight: innerHeight,
|
||
html: box("html"),
|
||
body: box("body"),
|
||
main: box(".app-main"),
|
||
view: box("#auctionView"),
|
||
primary: box(".auction-primary-card"),
|
||
frame: box(".auction-table-frame-v2"),
|
||
side: box(".auction-side-v2"),
|
||
};
|
||
});
|
||
|
||
for (const viewport of [{ width: 1920, height: 1080 }, { width: 1536, height: 864 }]) {
|
||
await page.setViewportSize(viewport);
|
||
const layout = await measure();
|
||
expect(layout.html.scrollHeight).toBeLessThanOrEqual(layout.viewportHeight);
|
||
expect(layout.body.scrollHeight).toBeLessThanOrEqual(layout.viewportHeight);
|
||
expect(layout.main.overflowY).toBe("hidden");
|
||
expect(layout.frame.overflowY).toBe("auto");
|
||
expect(layout.frame.scrollHeight).toBeGreaterThan(layout.frame.clientHeight);
|
||
expect(layout.side.top).toBeLessThan(layout.primary.bottom);
|
||
}
|
||
|
||
await page.setViewportSize({ width: 1280, height: 720 });
|
||
const compact = await measure();
|
||
expect(compact.html.scrollHeight).toBeLessThanOrEqual(compact.viewportHeight);
|
||
expect(compact.main.overflowY).toBe("hidden");
|
||
expect(compact.frame.overflowY).toBe("auto");
|
||
expect(compact.frame.scrollHeight).toBeGreaterThan(compact.frame.clientHeight);
|
||
expect(compact.side.top).toBeLessThan(compact.primary.bottom);
|
||
|
||
await page.setViewportSize({ width: 3840, height: 2160 });
|
||
const wide = await measure();
|
||
expect(wide.html.scrollHeight).toBeLessThanOrEqual(wide.viewportHeight);
|
||
expect(wide.view.width).toBeLessThanOrEqual(wide.main.clientWidth + 1);
|
||
expect(wide.view.scrollWidth).toBeLessThanOrEqual(wide.view.clientWidth + 1);
|
||
});
|
||
|
||
test("regular account cannot see admin controls and member features are gated", async ({ page }) => {
|
||
await mockApplication(page, session("user", false));
|
||
await page.goto("/index.html");
|
||
await expect(page.locator("#settingsButton")).toBeHidden();
|
||
await expect(page.locator("#syncButton")).toBeHidden();
|
||
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
||
await page.locator('[data-view="screenerView"]').first().click();
|
||
await expect(page.locator("#screenerView .member-gate")).toBeVisible();
|
||
await page.locator('[data-screener-mode="quant"]').click();
|
||
await expect(page.locator("#quantRunButton")).toBeDisabled();
|
||
await page.locator("#assistantButton").click();
|
||
await expect(page.locator("#settingsDialog")).toBeHidden();
|
||
await expect(page.locator("#assistantDialog")).toBeVisible();
|
||
await expect(page.locator("#assistantMemberGate")).toContainText("复盘助手仅对会员开放");
|
||
await expect(page.locator("#assistantMemberGate")).toBeVisible();
|
||
await expect(page.locator("#assistantMemberContent")).toHaveAttribute("aria-disabled", "true");
|
||
await expect(page.locator("#assistantQuestion")).toBeDisabled();
|
||
await expect(page.locator("[data-assistant-prompt]").first()).toBeDisabled();
|
||
await expect(page.locator("#sendAssistant")).toBeDisabled();
|
||
});
|
||
|
||
test("stock hover preview ignores the selected historical date", async ({ page }) => {
|
||
await mockApplication(page, session("user", true));
|
||
await page.goto("/index.html");
|
||
await page.locator("#tradeDate").fill("2026-07-01");
|
||
const requestPromise = page.waitForRequest((request) => request.url().includes("/api/stock/002141/preview"));
|
||
await page.evaluate(() => showStockPreview("002141", document.querySelector("#globalSearchButton")));
|
||
const request = await requestPromise;
|
||
const requestUrl = new URL(request.url());
|
||
expect(requestUrl.searchParams.has("trade_date")).toBe(false);
|
||
await expect(page.locator("#stockPreviewDate")).toHaveText("2026-07-23");
|
||
await expect(page.locator("#stockPreviewName")).toHaveText("Test Stock");
|
||
await expect(page.locator("#stockPreviewSource")).toHaveText("日 K 行情 · 2 个交易日");
|
||
await expect(page.locator('[data-preview-chart="daily"]')).toHaveClass(/active/);
|
||
await page.locator('[data-preview-chart="intraday"]').click();
|
||
await expect(page.locator("#stockPreviewDate")).toHaveText("2026-07-24");
|
||
await expect(page.locator("#stockPreviewSource")).toHaveText("最新分时 · 1分钟");
|
||
const canvasColors = await page.locator("#stockPreviewChart").evaluate((canvas) => {
|
||
const pixels = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height).data;
|
||
const colors = new Set();
|
||
for (let index = 0; index < pixels.length; index += 16) {
|
||
if (pixels[index + 3]) colors.add(`${pixels[index]},${pixels[index + 1]},${pixels[index + 2]}`);
|
||
}
|
||
return colors.size;
|
||
});
|
||
expect(canvasColors).toBeGreaterThan(4);
|
||
});
|
||
|
||
test("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")).toHaveCount(0);
|
||
await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4);
|
||
|
||
await page.locator('[data-screener-mode="quant"]').click();
|
||
await expect(page.locator('[data-screener-panel="curated"]')).toBeHidden();
|
||
await expect(page.locator('[data-screener-panel="quant"]')).toBeVisible();
|
||
await expect(page.locator("#quantFilterRows .quant-filter-row")).toHaveCount(2);
|
||
await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5);
|
||
await expect(page.locator('#quantScoreRows input[data-quant-key="weight"]').first()).toHaveAttribute("type", "range");
|
||
await expect(page.locator("#quantWeightTotal")).toHaveText("100%");
|
||
await expect(page.locator("#quantRunButton")).toBeEnabled();
|
||
|
||
await page.locator("#addQuantFilterButton").click();
|
||
await expect(page.locator("#quantFilterRows .quant-filter-row")).toHaveCount(3);
|
||
const lastScore = page.locator("#quantScoreRows .quant-score-row").last();
|
||
await expect(lastScore.locator('[data-quant-action="direction"]')).toHaveText("数值越低越优");
|
||
await lastScore.locator('[data-quant-action="direction"]').click();
|
||
await expect(lastScore.locator('[data-quant-action="direction"]')).toHaveText("数值越高越优");
|
||
});
|
||
|
||
test("screener refresh restores every mode and deduplicates setup loading", async ({ page }) => {
|
||
const candidate = (code, name) => ({
|
||
code, name, sector: "测试板块", score_display: 80, historical_probability: 50,
|
||
probability_samples: 20, pct_chg: 1, return_5d: 2, volume_ratio_5d: 1.2,
|
||
sector_strength: 70, reason: "测试来源", risk_flags: [],
|
||
});
|
||
const result = (mode, runId, strategyName, row) => ({
|
||
meta: {
|
||
run_id: runId, trade_date: "20260722", regime: "repair",
|
||
strategy_name: strategyName, mode,
|
||
},
|
||
candidates: [row],
|
||
disclaimer: "历史统计不代表未来收益",
|
||
backtest: null,
|
||
});
|
||
const options = {
|
||
latestScreenerResults: {
|
||
smart: result("smart", 61, "修复确认", candidate("600001", "阶段恢复")),
|
||
curated: result("curated", 62, "连续分红质量", candidate("600002", "策略恢复")),
|
||
quant: result("quant", 63, "自定义量化公式", candidate("600003", "量化恢复")),
|
||
},
|
||
};
|
||
await mockApplication(page, session("user", true), options);
|
||
await page.goto("/index.html?view=screenerView");
|
||
await expect(page.locator("#screenerTableBody")).toContainText("阶段恢复");
|
||
expect(options.screenerSetupRequests).toBe(1);
|
||
|
||
await page.locator('[data-screener-mode="curated"]').click();
|
||
await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量");
|
||
await expect(page.locator("#screenerTableBody")).toContainText("策略恢复");
|
||
await page.locator('[data-screener-mode="quant"]').click();
|
||
await expect(page.locator("#screenerTableBody")).toContainText("量化恢复");
|
||
|
||
await page.reload();
|
||
await expect(page.locator('[data-screener-mode="quant"]')).toHaveClass(/active/);
|
||
await expect(page.locator("#screenerTableBody")).toContainText("量化恢复");
|
||
expect(options.screenerSetupRequests).toBe(2);
|
||
|
||
await page.locator("#quantRunButton").click();
|
||
await expect.poll(() => options.screenerRunBodies?.length || 0).toBe(1);
|
||
expect(options.screenerRunBodies[0].mode).toBe("quant");
|
||
});
|
||
|
||
test("screener redesign preserves three clear workspaces across desktop and mobile", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1440, height: 900 });
|
||
await mockApplication(page, session("user", true));
|
||
await page.goto("/index.html");
|
||
await page.locator('[data-view="screenerView"]').first().click();
|
||
|
||
await expect(page.locator("#screenerView .screener-page-bar")).toBeVisible();
|
||
await expect(page.locator("#screenerView .screener-step")).toHaveCount(4);
|
||
await expect(page.locator("#regimeLabel")).toHaveText("修复");
|
||
await expect(page.locator("#screenerView .regime-temperature")).toHaveCount(0);
|
||
await expect(page.locator("#regimeEvidenceList")).toContainText("情绪温度");
|
||
const overviewCards = page.locator("#screenerView .screener-overview-card");
|
||
await expect(overviewCards).toHaveCount(2);
|
||
const [regimeBox, strategyBox] = await Promise.all([
|
||
overviewCards.nth(0).boundingBox(),
|
||
overviewCards.nth(1).boundingBox(),
|
||
]);
|
||
expect(Math.abs(regimeBox.y - strategyBox.y)).toBeLessThanOrEqual(1);
|
||
expect(strategyBox.x).toBeGreaterThan(regimeBox.x + regimeBox.width - 2);
|
||
expect(regimeBox.height).toBeLessThanOrEqual(155);
|
||
expect(strategyBox.height).toBeLessThanOrEqual(155);
|
||
for (const line of await page.locator("#screenerView .step-line").all()) {
|
||
expect((await line.boundingBox()).width).toBeLessThanOrEqual(42);
|
||
}
|
||
const [strategyDescriptionBox, strategyActionsBox] = await Promise.all([
|
||
page.locator("#activeStrategyDescription").boundingBox(),
|
||
page.locator(".screener-strategy-actions").boundingBox(),
|
||
]);
|
||
expect(strategyActionsBox.y - (strategyDescriptionBox.y + strategyDescriptionBox.height)).toBeLessThanOrEqual(8);
|
||
const trackingEntryStyle = await page.locator("#openScreenerTrackingButton").evaluate((element) => {
|
||
const style = getComputedStyle(element);
|
||
return { background: style.backgroundColor, weight: style.fontWeight };
|
||
});
|
||
expect(trackingEntryStyle.background).not.toBe("rgb(255, 255, 255)");
|
||
expect(Number(trackingEntryStyle.weight)).toBeGreaterThanOrEqual(700);
|
||
await page.evaluate(() => {
|
||
setScreenerResult("smart", {
|
||
meta: { run_id: 46, trade_date: "20260722", realtime: false, regime: "repair", strategy_name: "修复确认" },
|
||
disclaimer: "历史统计不代表未来收益",
|
||
candidates: [{
|
||
code: "600000", ts_code: "600000.SH", name: "浦发银行", sector: "银行", price: 12,
|
||
score_display: 82, historical_probability: 45, probability_samples: 30,
|
||
pct_chg: 1.2, return_5d: 3.4, volume_ratio_5d: 1.5, sector_strength: 78,
|
||
reason: "板块强度、相对强度", risk_flags: [],
|
||
}],
|
||
backtest: {
|
||
samples: 35, win_rate: 8.6, average_3d_return: -5,
|
||
average_drawdown: -11.71, definition: "收盘后选股,未来3日按统一阈值验证。",
|
||
},
|
||
}, { regime: "repair", strategyId: 1, strategyName: "修复确认" });
|
||
renderScreenerResult();
|
||
});
|
||
const completedMarkers = page.locator('#screenerView .screener-step[data-state="complete"] .step-marker');
|
||
await expect(completedMarkers).toHaveCount(3);
|
||
await expect(page.locator("#backtestPanel")).toBeVisible();
|
||
expect((await page.locator("#backtestPanel").boundingBox()).height).toBeLessThanOrEqual(55);
|
||
await expect(page.locator("#backtestMetrics .dragon-metric span")).toHaveCount(4);
|
||
await expect(page.locator("#screenerResultSource")).toHaveText("阶段选股 · 修复 · 修复确认");
|
||
for (const metric of await page.locator("#backtestMetrics .dragon-metric").all()) {
|
||
const box = await metric.boundingBox();
|
||
expect(box.height).toBeLessThanOrEqual(40);
|
||
}
|
||
await page.screenshot({ path: "test-results/screener-stage15-phase-1440.png", fullPage: true });
|
||
|
||
await page.evaluate(() => {
|
||
state.screenerSetup.strategies.push({
|
||
id: 4, name: "低波质量", description: "用第二套策略验证整卡选择交互。", regimes: ["repair"], builtin: true,
|
||
data_ready: true, missing_data: [],
|
||
formula: {
|
||
meta: { library: "curated", category: "质量防守", quality: "A", frequency: "月度", risk: "低", data_group: "行情与质量" },
|
||
universe: { exclude_st: true, listed_days_min: 720 },
|
||
filters: [{ field: "return_20d", op: ">=", value: 0 }],
|
||
score: [{ field: "relative_strength", weight: 1, direction: "desc" }], limit: 20, min_score: 0.5,
|
||
},
|
||
});
|
||
renderCuratedStrategyLibrary();
|
||
});
|
||
await page.locator('[data-screener-mode="curated"]').click();
|
||
await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(2);
|
||
await expect(page.locator(".curated-detail-pane")).toBeVisible();
|
||
const secondStrategy = page.locator('#curatedStrategyList [data-curated-strategy="4"]');
|
||
await secondStrategy.click();
|
||
await expect(secondStrategy).toHaveClass(/active/);
|
||
await expect(page.locator('#curatedStrategyList [data-curated-strategy="2"]')).not.toHaveClass(/active/);
|
||
await expect(page.locator("#curatedStrategyName")).toHaveText("低波质量");
|
||
const [libraryBox, detailBox] = await Promise.all([
|
||
page.locator(".curated-library-pane").boundingBox(),
|
||
page.locator(".curated-detail-pane").boundingBox(),
|
||
]);
|
||
expect(detailBox.x).toBeGreaterThan(libraryBox.x + libraryBox.width - 2);
|
||
expect(Math.abs(detailBox.y - libraryBox.y)).toBeLessThanOrEqual(1);
|
||
expect(libraryBox.width).toBeLessThan(detailBox.width);
|
||
await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4);
|
||
await page.screenshot({ path: "test-results/screener-stage15-strategy-1440.png", fullPage: true });
|
||
|
||
await page.locator('[data-screener-mode="quant"]').click();
|
||
await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5);
|
||
await expect(page.locator("#screenerView .quant-intro-band")).toHaveCount(0);
|
||
await expect(page.getByText("执行设置", { exact: true })).toHaveCount(0);
|
||
await expect(page.locator("#screenerResultTitle")).toHaveText("自定义选股结果");
|
||
const [builderBox, summaryBox] = await Promise.all([
|
||
page.locator(".quant-builder-pane").boundingBox(),
|
||
page.locator(".quant-summary-pane").boundingBox(),
|
||
]);
|
||
expect(Math.abs(builderBox.y - summaryBox.y)).toBeLessThanOrEqual(1);
|
||
expect(summaryBox.x).toBeGreaterThan(builderBox.x + builderBox.width - 2);
|
||
expect(builderBox.width).toBeGreaterThanOrEqual(490);
|
||
expect(builderBox.width).toBeLessThanOrEqual(540);
|
||
const quantRunBox = await page.locator("#quantRunButton").boundingBox();
|
||
expect(quantRunBox.width).toBeLessThan(180);
|
||
expect((await page.locator("#quantFilterRows .quant-filter-row select").first().boundingBox()).width).toBeLessThanOrEqual(225);
|
||
await expect(page.locator('[data-screener-results-slot="quant"] > .screener-results-view')).toBeVisible();
|
||
await page.screenshot({ path: "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("automatic screener results stay read-only and mode results stay isolated", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1440, height: 900 });
|
||
await mockApplication(page, session("user", true));
|
||
await page.goto("/index.html");
|
||
await page.locator('[data-view="screenerView"]').first().click();
|
||
await page.evaluate(() => {
|
||
state.screenerSetup.regimes.push({ id: "retreat", label: "退潮" });
|
||
state.screenerSetup.strategies.push({
|
||
id: 3, name: "退潮防守", description: "仅保留抗跌方向", regimes: ["retreat"], builtin: true,
|
||
data_ready: true, missing_data: [],
|
||
formula: { meta: { library: "smart" }, universe: {}, filters: [], score: [], limit: 10, min_score: 0.5 },
|
||
});
|
||
const candidate = (code, name) => ({
|
||
code, name, sector: "测试板块", score_display: 80, historical_probability: 50,
|
||
probability_samples: 20, pct_chg: 1, return_5d: 2, volume_ratio_5d: 1.2,
|
||
sector_strength: 70, reason: "测试来源", risk_flags: [],
|
||
});
|
||
window.__screenerCandidate = candidate;
|
||
setScreenerResult("smart", {
|
||
meta: { run_id: 51, trade_date: "20260722", regime: "repair", strategy_name: "修复确认" },
|
||
disclaimer: "历史统计不代表未来收益", candidates: [candidate("600001", "阶段结果")],
|
||
backtest: { samples: 37, win_rate: 50, average_3d_return: 1, average_drawdown: -2, definition: "测试" },
|
||
}, { regime: "repair", strategyId: 1, strategyName: "修复确认" });
|
||
renderScreenerSetup();
|
||
});
|
||
|
||
await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(3);
|
||
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
|
||
await expect(page.locator('[data-regime]')).toHaveCount(0);
|
||
await expect(page.locator("#screenerRunButton")).toHaveCount(0);
|
||
await expect(page.locator("#syncScreenerButton")).toHaveCount(0);
|
||
await expect(page.locator("#changeStrategyButton")).toHaveCount(0);
|
||
await expect(page.locator("#editStrategyButton")).toHaveCount(0);
|
||
await page.locator('[data-screener-mode="curated"]').click();
|
||
await expect(page.locator("#screenerEmpty")).toContainText("所选策略");
|
||
await page.evaluate(() => {
|
||
setScreenerResult("curated", {
|
||
meta: { run_id: 52, trade_date: "20260722", regime: "repair", strategy_name: "连续分红质量" },
|
||
disclaimer: "历史统计不代表未来收益", candidates: [window.__screenerCandidate("600002", "策略结果")],
|
||
}, { regime: "repair", strategyId: 2, strategyName: "连续分红质量" });
|
||
renderScreenerResult();
|
||
});
|
||
await expect(page.locator("#screenerTableBody")).toContainText("策略结果");
|
||
await expect(page.locator("#screenerTableBody")).not.toContainText("阶段结果");
|
||
await expect(page.locator("#screenerResultSource")).toHaveText("策略选股 · 连续分红质量");
|
||
|
||
await page.locator('[data-screener-mode="quant"]').click();
|
||
await expect(page.locator("#screenerEmpty")).toContainText("自定义选股");
|
||
await page.evaluate(() => {
|
||
setScreenerResult("quant", {
|
||
meta: { run_id: 53, trade_date: "20260722", regime: "repair", strategy_name: "自定义量化公式" },
|
||
disclaimer: "历史统计不代表未来收益", candidates: [window.__screenerCandidate("600003", "量化结果")],
|
||
}, { regime: "repair", strategyName: "自定义量化公式" });
|
||
renderScreenerResult();
|
||
});
|
||
await expect(page.locator("#screenerTableBody")).toContainText("量化结果");
|
||
await expect(page.locator("#screenerResultSource")).toHaveText("自定义选股 · 自定义因子权重");
|
||
|
||
await page.locator('[data-screener-mode="smart"]').click();
|
||
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
|
||
await expect(page.locator("#screenerTableBody")).not.toContainText("策略结果");
|
||
await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果");
|
||
});
|
||
|
||
test("screener restores automatic stage and curated pools across switching and reload", async ({ page }) => {
|
||
const formula = {
|
||
meta: { library: "smart" }, universe: {}, filters: [],
|
||
score: [{ field: "relative_strength", weight: 1, direction: "desc" }],
|
||
limit: 10, min_score: 0.5,
|
||
};
|
||
const candidate = (code, name) => ({
|
||
code, name, sector: "Test Sector", score_display: 80,
|
||
historical_probability: 50, probability_samples: 20, pct_chg: 1,
|
||
return_5d: 2, volume_ratio_5d: 1.2, sector_strength: 70,
|
||
reason: "Context result", risk_flags: [],
|
||
});
|
||
const result = (mode, runId, strategyName, row) => ({
|
||
meta: {
|
||
run_id: runId, trade_date: "20260722", regime: "repair",
|
||
strategy_name: strategyName, mode,
|
||
},
|
||
candidates: [row],
|
||
disclaimer: "Historical statistics do not predict future returns.",
|
||
backtest: null,
|
||
});
|
||
const smartResult = result("smart", 101, "修复确认", candidate("600001", "Smart Repair"));
|
||
const curatedA = result("curated", 102, "连续分红质量", candidate("600002", "Curated A"));
|
||
const curatedB = result("curated", 103, "Quality B", candidate("600003", "Curated B"));
|
||
const options = {
|
||
latestScreenerResults: { smart: smartResult, curated: curatedA },
|
||
recentScreenerResults: [smartResult, curatedA, curatedB],
|
||
additionalScreenerStrategies: [
|
||
{
|
||
id: 4, name: "Quality B", description: "Second curated strategy",
|
||
regimes: ["repair"], builtin: true, data_ready: true, missing_data: [],
|
||
formula: {
|
||
...formula,
|
||
meta: { library: "curated", category: "Quality", quality: "A", frequency: "Monthly", risk: "Low" },
|
||
},
|
||
},
|
||
],
|
||
};
|
||
|
||
await mockApplication(page, session("user", true), options);
|
||
await page.goto("/index.html?view=screenerView");
|
||
|
||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
|
||
await expect(page.locator("#screenerRunButton")).toHaveCount(0);
|
||
|
||
await page.locator('[data-screener-mode="curated"]').click();
|
||
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
|
||
await page.locator('[data-curated-strategy="4"]').click();
|
||
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
|
||
await page.locator('[data-curated-strategy="2"]').click();
|
||
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
|
||
expect(options.screenerRunBodies || []).toHaveLength(0);
|
||
|
||
await page.reload();
|
||
await page.locator('[data-screener-mode="smart"]').click();
|
||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
|
||
await page.locator('[data-screener-mode="curated"]').click();
|
||
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
|
||
await page.locator('[data-curated-strategy="4"]').click();
|
||
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
|
||
});
|
||
|
||
test("screener tracking is an internal page populated only by manual candidate actions", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1440, height: 900 });
|
||
await mockApplication(page, session("user", true));
|
||
await page.goto("/index.html");
|
||
await page.locator('[data-view="screenerView"]').first().click();
|
||
await page.evaluate(() => {
|
||
setScreenerResult("smart", {
|
||
meta: { run_id: 45, trade_date: "20260722", realtime: false, regime: "repair", strategy_name: "修复确认" },
|
||
disclaimer: "历史统计不代表未来收益",
|
||
candidates: [{
|
||
code: "600000", ts_code: "600000.SH", name: "浦发银行", sector: "银行", price: 12,
|
||
score_display: 82, historical_probability: 45, probability_samples: 30,
|
||
pct_chg: 1.2, return_5d: 3.4, volume_ratio_5d: 1.5, sector_strength: 78,
|
||
reason: "板块强度、相对强度", risk_flags: [],
|
||
}],
|
||
}, { regime: "repair", strategyId: 1, strategyName: "修复确认" });
|
||
renderScreenerResult();
|
||
});
|
||
|
||
const addButton = page.locator('[data-add-tracking="600000"]');
|
||
await expect(addButton).toHaveText("加入跟踪");
|
||
await addButton.click();
|
||
await expect(page.locator('[data-add-tracking="600000"]')).toHaveText("已跟踪");
|
||
|
||
await page.locator("#openScreenerTrackingButton").click();
|
||
await expect(page.locator("#screenerTrackingView")).toHaveClass(/active-view/);
|
||
await expect(page.locator('[data-view="screenerView"]').first()).toHaveClass(/active/);
|
||
await expect(page.locator("#trackingTableBody tr")).toHaveCount(2);
|
||
await expect(page.locator('[data-view="screenerTrackingView"]')).toHaveCount(0);
|
||
await page.screenshot({ path: "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 .wt-title-line h1").evaluate((element) => getComputedStyle(element).fontSize));
|
||
expect(titleSize).toBeLessThanOrEqual(32);
|
||
|
||
const trendButtons = page.locator(".heaven-trend-actions .button");
|
||
await expect(trendButtons).toHaveCount(3);
|
||
for (let index = 0; index < await trendButtons.count(); index += 1) {
|
||
const box = await trendButtons.nth(index).boundingBox();
|
||
expect(box.width).toBeLessThanOrEqual(130);
|
||
expect(box.height).toBeLessThanOrEqual(44);
|
||
}
|
||
await page.locator('[data-heaven-panel="fortune"]').click();
|
||
const fortuneActions = await page.locator("#heavenFortunePanel .fortune-heading-actions").boundingBox();
|
||
const fortunePanel = await page.locator("#heavenFortunePanel").boundingBox();
|
||
expect(fortuneActions.x + fortuneActions.width).toBeLessThanOrEqual(fortunePanel.x + fortunePanel.width + 1);
|
||
await page.evaluate(() => {
|
||
state.heavenInterpretations.fortune = {
|
||
id: 31,
|
||
mode: "fortune",
|
||
context_date: "20260723",
|
||
subject: "2026-07-23 观气",
|
||
subject_detail: "丙午年 · 乙未月 · 己丑日",
|
||
answer: "三层气机已经合参,今日宜先定节奏,再看行动。",
|
||
created_at: "2026-07-23T09:12:00+08:00",
|
||
};
|
||
openHeavenReading("fortune", { loading: false });
|
||
});
|
||
const readingDialog = await page.locator("#heavenReadingDialog").boundingBox();
|
||
expect(readingDialog.width).toBeLessThanOrEqual(1120);
|
||
expect(readingDialog.width / readingDialog.height).toBeGreaterThan(1.4);
|
||
expect(readingDialog.height).toBeLessThanOrEqual(820);
|
||
const readingHeader = await page.locator("#heavenReadingDialog .dialog-header").boundingBox();
|
||
const readingTabs = await page.locator("#heavenReadingDialog .heaven-reading-tabs").boundingBox();
|
||
expect(readingHeader.y + readingHeader.height).toBeLessThanOrEqual(readingTabs.y + 1);
|
||
await page.locator("#closeHeavenReadingDialog").click();
|
||
|
||
await page.locator('[data-heaven-panel="heart"]').click();
|
||
const heartControls = await page.locator(".heart-toolbar-controls").boundingBox();
|
||
const heartPanel = await page.locator("#heavenHeartPanel").boundingBox();
|
||
expect(heartControls.width).toBeLessThanOrEqual(190);
|
||
expect(heartControls.x + heartControls.width).toBeLessThanOrEqual(heartPanel.x + heartPanel.width + 1);
|
||
const historyBox = await page.locator("#historyHeartButton").boundingBox();
|
||
const soundBox = await page.locator("#heartSoundToggle").boundingBox();
|
||
expect(historyBox.width).toBeLessThanOrEqual(100);
|
||
expect(historyBox.x + historyBox.width).toBeLessThanOrEqual(soundBox.x);
|
||
|
||
});
|
||
|
||
test("heaven workspace controls fit a narrow viewport", async ({ page }) => {
|
||
await page.setViewportSize({ width: 375, height: 812 });
|
||
await mockApplication(page, session("user", true));
|
||
await page.goto("/index.html");
|
||
await page.locator('[data-view="heavenView"]').first().click();
|
||
expect(await page.evaluate(() => window.scrollY)).toBe(0);
|
||
|
||
const trendActions = await page.locator(".heaven-trend-actions").boundingBox();
|
||
expect(trendActions.width).toBeLessThanOrEqual(347);
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
|
||
await page.locator('[data-heaven-panel="fortune"]').click();
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
|
||
const fortuneButtons = page.locator("#heavenFortunePanel .fortune-heading-actions .button");
|
||
const firstFortuneButton = await fortuneButtons.first().boundingBox();
|
||
const secondFortuneButton = await fortuneButtons.last().boundingBox();
|
||
expect(Math.abs(firstFortuneButton.width - secondFortuneButton.width)).toBeLessThanOrEqual(1);
|
||
expect(Math.abs(firstFortuneButton.y - secondFortuneButton.y)).toBeLessThanOrEqual(1);
|
||
await page.locator('[data-heaven-panel="heart"]').click();
|
||
const heartControls = await page.locator(".heart-toolbar-controls").boundingBox();
|
||
const heartPanel = await page.locator("#heavenHeartPanel").boundingBox();
|
||
expect(heartControls.width).toBeLessThanOrEqual(heartPanel.width);
|
||
expect(heartControls.x).toBeGreaterThanOrEqual(heartPanel.x - 1);
|
||
expect(heartControls.x + heartControls.width).toBeLessThanOrEqual(375);
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
|
||
});
|
||
|
||
test("mentor directory exposes evidence filters and private owner metadata", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1440, height: 900 });
|
||
await mockApplication(page, session("admin", true));
|
||
await page.goto("/index.html");
|
||
await page.locator('[data-view="mentorView"]').first().click();
|
||
|
||
await expect(page.locator("#mentorView .mentor-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')).toHaveCount(0);
|
||
|
||
await page.locator("#mentorSearchInput").fill("行为推演");
|
||
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(1);
|
||
await expect(page.locator("#mentorCount")).toHaveText("1 / 22 位");
|
||
await page.locator("#mentorSearchInput").fill("");
|
||
await page.locator('[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);
|
||
await page.locator("#themeToggle").click();
|
||
const darkMessageStyle = await answer.evaluate((element) => {
|
||
const style = getComputedStyle(element);
|
||
return {
|
||
background: style.backgroundColor,
|
||
border: style.borderTopColor,
|
||
shadow: style.boxShadow,
|
||
};
|
||
});
|
||
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)");
|
||
expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)");
|
||
expect(darkMessageStyle.shadow).toBe("none");
|
||
});
|
||
|
||
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);
|
||
});
|