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