施工(HEL-129): P5 收官打磨——修复5处陈旧桌面e2e用例被手机分流劫持,新增手机端全页面回归套件

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员
2026-08-25 09:53:41 +08:00
co-authored by multica-agent
parent 304b948a2d
commit 1fd69b1dcf
2 changed files with 352 additions and 5 deletions
+6 -5
View File
@@ -1986,7 +1986,7 @@ test("stylesheet layers do not repeat identical rules in the same cascade contex
test("mobile shell stays within the viewport", async ({ page }) => { test("mobile shell stays within the viewport", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 }); await page.setViewportSize({ width: 375, height: 812 });
await mockApplication(page, session("user", true)); await mockApplication(page, session("user", true));
await page.goto("/index.html"); await page.goto("/index.html?ui=desktop");
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1); expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator("#globalSearchButton")).toBeHidden(); await expect(page.locator("#globalSearchButton")).toBeHidden();
@@ -2013,7 +2013,7 @@ test("mobile shell stays within the viewport", async ({ page }) => {
test("mobile shell remains usable at supported narrow widths", async ({ page }) => { test("mobile shell remains usable at supported narrow widths", async ({ page }) => {
await page.setViewportSize({ width: 430, height: 932 }); await page.setViewportSize({ width: 430, height: 932 });
await mockApplication(page, session("user", true)); await mockApplication(page, session("user", true));
await page.goto("/index.html"); await page.goto("/index.html?ui=desktop");
const registeredViews = await page.evaluate(() => window.XiaobaiPages.pages.map((entry) => entry.id)); const registeredViews = await page.evaluate(() => window.XiaobaiPages.pages.map((entry) => entry.id));
for (const viewport of [ for (const viewport of [
{ width: 430, height: 932 }, { width: 430, height: 932 },
@@ -2730,7 +2730,7 @@ for (const viewport of [
test(`member workflows fit a mobile ${viewport.name} viewport`, async ({ page }) => { test(`member workflows fit a mobile ${viewport.name} viewport`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height }); await page.setViewportSize({ width: viewport.width, height: viewport.height });
await mockApplication(page, session("user", true)); await mockApplication(page, session("user", true));
await page.goto("/index.html"); await page.goto("/index.html?ui=desktop");
await page.locator('[data-view="reviewWorkspaceView"]').first().click(); await page.locator('[data-view="reviewWorkspaceView"]').first().click();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1); expect(overflow).toBeLessThanOrEqual(1);
@@ -2829,7 +2829,7 @@ test("heaven workspace actions remain compact and do not overlap", async ({ page
test("heaven workspace controls fit a narrow viewport", async ({ page }) => { test("heaven workspace controls fit a narrow viewport", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 }); await page.setViewportSize({ width: 375, height: 812 });
await mockApplication(page, session("user", true)); await mockApplication(page, session("user", true));
await page.goto("/index.html"); await page.goto("/index.html?ui=desktop");
await page.locator('[data-view="heavenView"]').first().click(); await page.locator('[data-view="heavenView"]').first().click();
expect(await page.evaluate(() => window.scrollY)).toBe(0); expect(await page.evaluate(() => window.scrollY)).toBe(0);
@@ -3562,7 +3562,8 @@ test("header username stays untruncated on desktop and in the mobile menu", asyn
for (const account of accounts) { for (const account of accounts) {
await mockApplication(page, account.auth); await mockApplication(page, account.auth);
await page.goto("/index.html"); await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/index.html?ui=desktop");
await expect(page.locator("#accountName")).toHaveText(account.username); await expect(page.locator("#accountName")).toHaveText(account.username);
for (const theme of ["day", "night"]) { for (const theme of ["day", "night"]) {
+346
View File
@@ -0,0 +1,346 @@
const { test, expect } = require("@playwright/test");
// 手机端(/m/)全页面回归:P5 收官打磨。
// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手,
// 以及日夜两套渲染、空态、错误态、横屏健壮性、深底深字对比度抽查。
const EMPTY_DASHBOARD = {
meta: {
trade_date: "2026-07-22",
requested_date: "2026-07-22",
source: "tushare",
realtime: false,
cached: true,
market_status: "closed",
previous_trade_date: "2026-07-21",
},
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: [],
};
const DASHBOARD = {
...EMPTY_DASHBOARD,
limits: [
{ code: "002141", name: "贤丰控股", streak: 4, change: 9.98, 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: "板块龙头连板打开空间" },
],
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: "芯片方向冲高回落" },
],
down_limits: [
{ code: "000037", name: "深南电A", change: -10.03, price: 8.97, sector: "电力", turnover_rate: 11.78, amount_billion: 3.68, streak: 2, reason: "连续弱势跌停" },
],
yesterday_limits: [
{ code: "000011", name: "深物业A", prior_streak: 1, current_change: 9.99, outcome: "晋级", current_streak: 2, sector: "房地产开发", reason: "地产政策预期" },
],
limit_performance: [
{ 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 },
],
ladders: [
{ level: 4, label: "4板", count: 1, stocks: [{ code: "002141", name: "贤丰控股", sector: "电子元件", first_time: "09:31", open_times: 0, seal_amount_million: 8200 }] },
{ level: 3, label: "3板", count: 2, stocks: [{ code: "000011", name: "深物业A", sector: "房地产开发", first_time: "09:40", open_times: 1, amount_billion: 3.2 }] },
],
};
function authSession(role = "admin", subscribed = true) {
return {
authenticated: true,
csrf_token: "mobile-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" },
},
};
}
async function mockMobileApi(page, options = {}) {
const auth = options.auth || authSession();
const dashboard = options.emptyDashboard ? EMPTY_DASHBOARD : DASHBOARD;
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
let payload = { ok: true };
if (options.failDashboard && path === "/api/dashboard") {
await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "行情服务暂时不可用" }) });
return;
}
if (path === "/api/auth/me") payload = auth;
else if (path === "/api/dashboard") payload = dashboard;
else if (path === "/api/popularity") {
const hot = { rank: 1, code: "002141", ts_code: "002141.SZ", name: "贤丰控股", change: 2.4, price: 12.48, ths_rank: 1, dc_rank: 2, rank_change: 3, concepts: ["电子元件"], dual_source: true };
payload = { meta: { trade_date: "2026-07-22" }, combined: [hot], ths: [{ ...hot }], dc: [{ ...hot, rank: 2 }] };
} else if (path === "/api/sentiment/history") {
payload = { rows: [{ trade_date: "2026-07-22", score: 48, phase: "修复", direction: "升温", label: "情绪修复", day_change: 4, seal_rate: 71, limit_up_count: 42, broken_count: 17, components: {} }] };
} else if (path === "/api/rotation/history") {
payload = { rows: [{ trade_date: "2026-07-22", sectors: [{ name: "人工智能", rank: 1, count: 8, strength: 88 }] }] };
} else if (path === "/api/rotation/members") {
payload = { meta: { trade_date: "20260722", sector_name: "人工智能", member_count: 1, quoted_count: 1 }, rows: [{ code: "002141", name: "贤丰控股", change: 4.8, open: 18.21, close: 19.06, amount_billion: 19.6 }] };
} else if (path === "/api/auction") {
payload = {
meta: { trade_date: "2026-07-22", phase: "finalized", available: true },
summary: { stock_count: 3, focus_count: 1, one_price_count: 1, amount_billion: 2.5 },
amount_history: [{ trade_date: "2026-07-22", amount_billion: 2.5, stock_count: 2 }],
themes: { carry: [{ name: "电子元件", status: "强承接", prior_limit_count: 2, leader: "贤丰控股", matched_count: 1, median_change: 4.2, amount_million: 15 }] },
focus_rows: [{ code: "002141", name: "贤丰控股", sector: "电子元件", change: 4.2, price: 12.48, amount_million: 15, volume_ratio: 1.8, expectation: "超预期", attention_score: 88.5 }],
rows: [],
one_price_rows: [],
watchlist_rows: [],
};
} else if (path === "/api/themes") {
payload = { meta: { trade_date: "2026-07-22" }, summary: { theme_count: 1, up_count: 1, down_count: 0, hot_count: 1 }, items: [{ code: "885728.TI", name: "人工智能", member_count: 8, change: 2.2, turnover_rate: 3.1, hot_rank: 1, has_quote: true }] };
} else if (path === "/api/themes/detail") {
payload = { meta: { trade_date: "2026-07-22" }, theme: { code: "885728.TI", name: "人工智能", member_count: 8, change: 2.2, turnover_rate: 3.1 }, summary: { member_count: 8, quoted_count: 8, up_count: 6, down_count: 2 }, members: [{ code: "002141", name: "贤丰控股", change: 2.4, price: 12.48, amount_billion: 3.2 }] };
} else if (path === "/api/dragon-tiger") {
payload = { meta: { trade_date: "2026-07-22", status: "ok" }, summary: { trader_count: 1, operation_count: 2, active_stock_count: 1 }, traders: [{ id: "t1", name: "赵老哥", identity_type: "trader", recognized: true, stock_count: 1, operation_count: 2, net_buy_million: 150, operations: [] }], unclassified_seats: [] };
} else if (path === "/api/dragon-tiger/profiles") {
payload = { meta: { status: "success" }, summary: { profile_count: 1, described_count: 1, organization_count: 1 }, profiles: [{ id: "p1", name: "赵老哥", description: "聚焦核心。", organizations: ["华泰证券浙江分公司"], organization_count: 1 }] };
} else if (path === "/api/screener/setup") {
payload = {
trade_date: "20260722",
regime: { id: "repair", label: "修复", confidence: 70, reason: "测试" },
regimes: [{ id: "repair", label: "修复" }],
factor_data: { ready: true, date_count: 45 },
strategies: [{ id: 1, name: "修复确认", description: "保留原有流程", regimes: ["repair"], builtin: true, data_ready: true, missing_data: [], formula: { meta: { library: "smart" }, universe: { exclude_st: true }, filters: [], score: [], limit: 15 } }],
};
} else if (path === "/api/screener/tracking") {
payload = { batches: [], summary: { total: 0, observed: 0, t1_win_rate: null, t5_win_rate: null, average_t5: null } };
} else if (path === "/api/watchlist") {
payload = { items: [{ code: "000002", name: "万科A", sector: "房地产开发", color: "red", change: 1.86, return_5d: 8.92, attention_score: 72.4, remark: "" }] };
} else if (path === "/api/trades") {
payload = { items: [{ id: 7, trade_date: "20260722", code: "002141", name: "贤丰控股", action: "buy", action_label: "买入", price: 10.2, quantity: 1000, position_pct: 20, pnl_amount: null, pnl_pct: null, emotion: "calm", emotion_label: "平静", tags: [], thesis: "", execution: "" }], summary: { total: 1, realized: 0, win_rate: null, pnl_amount: null, average_position: 20 } };
} else if (path === "/api/notes") {
payload = { items: [{ id: 12, code: "002141", stock_name: "贤丰控股", trade_date: "20260722", summary: "缩量修复", content: "等待确认。", plan: "" }] };
} else if (path === "/api/alerts") {
payload = { items: [{ id: 11, kind: "manual", available_date: "20260722", title: "复盘开盘强度", content: "", code: "002141", is_read: false, due: true }], unread_count: 1 };
} else if (path === "/api/assistant/messages") {
payload = { items: [] };
} else if (path === "/api/mentors/setup") {
payload = { trade_date: "20260722", mentors: [{ id: "source-a", name: "原帖老师", tagline: "先看周期。", focus: ["情绪周期"], evidence: { grade: "A" }, private: false }] };
} else if (path === "/api/mentors/messages") {
payload = { items: [] };
} else if (path === "/api/search") {
payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "贤丰控股", type: "stock", industry: "电子元件" }], sectors: [], themes: [], indices: [] } };
} else if (/^\/api\/stock\/\d+\/preview$/.test(path)) {
payload = {
meta: { trade_date: "2026-07-22", intraday_status: "available" },
stock: { code: "002141", name: "贤丰控股", industry: "电子元件", price: 12.48, change: 2.4 },
prices: [{ trade_date: "2026-07-21", open: 10, high: 10.5, low: 9.9, close: 10.2, volume: 1000 }, { trade_date: "2026-07-22", open: 10.3, high: 10.9, low: 10.2, close: 12.48, volume: 1200 }],
intraday: [{ date: "2026-07-22", time: "09:30", open: 10.2, high: 10.24, low: 10.18, close: 10.22, volume: 100, average: 10.22 }],
};
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(payload) });
});
}
async function openMobile(page) {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/m/");
await expect(page.locator("#m-boot-splash")).toBeHidden();
await expect(page.locator("#m-tabbar")).toBeVisible();
}
async function setMobileTheme(page, theme) {
const wanted = theme === "night" ? "dark" : "light";
await page.evaluate((value) => window.MobileTheme.applyTheme(value), wanted);
await expect(page.locator("#m-app")).toHaveAttribute("data-theme", wanted);
}
async function navigateToFeature(page, key) {
await page.evaluate((k) => { window.MobileRouter.navigate("#/feature/" + k); }, key);
await expect(page.locator(`#m-view .m-page[data-page="${key}"]`)).toBeVisible();
}
function measureOverflow(page) {
return page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
}
// 深底深字 / 隐形文字抽查:文本元素的前景色若与其实际背景色完全一致即为不可见文字。
function findInvisibleText(page) {
return page.evaluate(() => {
const issues = [];
const seen = new Set();
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const text = node.textContent.trim();
if (!text || text.length < 1) continue;
const el = node.parentElement;
if (!el || seen.has(el)) continue;
seen.add(el);
const style = getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") continue;
let bg = "transparent";
let cur = el;
while (cur && (bg === "transparent" || bg === "rgba(0, 0, 0, 0)")) {
bg = getComputedStyle(cur).backgroundColor;
cur = cur.parentElement;
}
if (style.color === bg) {
issues.push({ tag: el.tagName, cls: String(el.className).slice(0, 40), text: text.slice(0, 24), color: style.color });
}
}
return issues;
});
}
const MARKET_PAGES = [
["market/sentiment", "情绪周期"],
["market/limit-up", "涨停池"],
["market/broken", "炸板池"],
["market/limit-down", "跌停池"],
["market/yesterday", "昨日涨停"],
["market/performance", "涨停表现"],
["market/ladder", "市场天梯"],
["market/rotation", "主题轮动"],
["market/auction", "竞价"],
["market/themes", "题材库"],
["market/popularity", "人气榜"],
["market/dragon", "龙虎榜"],
];
const TOOL_PAGES = [
["tools/screener", "智能选股"],
["tools/tracking", "策略跟踪"],
["tools/mentor", "问师"],
];
const REVIEW_PAGES = [
["review/watchlist", "自选股"],
["review/trades", "交易日志"],
["review/daily", "每日复盘"],
["review/notes", "个股笔记"],
["review/alerts", "提醒中心"],
];
test("mobile login renders before authentication", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname === "/api/auth/me") {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ authenticated: false }) });
return;
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }) });
});
await page.goto("/m/");
await expect(page.locator("#m-auth-form")).toBeVisible();
await expect(page.locator("#m-auth-username")).toBeVisible();
await expect(page.locator("#m-tabbar")).toBeHidden();
});
test("four hub pages render their icon grids", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
for (const hub of ["market", "tools", "review", "system"]) {
await page.evaluate((h) => { window.MobileRouter.navigate("#/hub/" + h); }, hub);
await expect(page.locator(".m-hub-grid")).toBeVisible();
await expect(page.locator(".m-hub-grid .m-grid-item").first()).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
}
});
for (const theme of ["day", "night"]) {
test(`market 12 pages render without overflow or invisible text (${theme})`, async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await setMobileTheme(page, theme);
for (const [key, label] of MARKET_PAGES) {
await navigateToFeature(page, key);
await expect(page.locator("#m-title")).toHaveText(label);
await expect(page.locator("#m-view .m-page")).toBeVisible();
await page.waitForTimeout(120);
expect(await measureOverflow(page), `${key} overflows in ${theme}`).toBeLessThanOrEqual(1);
const invisible = await findInvisibleText(page);
expect(invisible, `${key} invisible text in ${theme}: ${JSON.stringify(invisible)}`).toEqual([]);
}
});
}
test("tools pages render screener, tracking and mentor", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
for (const [key, label] of TOOL_PAGES) {
await navigateToFeature(page, key);
await expect(page.locator("#m-title")).toHaveText(label);
await page.waitForTimeout(120);
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
}
});
test("review five pages render watchlist, trades, daily, notes and alerts", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
for (const [key, label] of REVIEW_PAGES) {
await navigateToFeature(page, key);
await expect(page.locator("#m-title")).toHaveText(label);
await page.waitForTimeout(120);
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
}
});
test("assistant chat renders with presets and input", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await page.evaluate(() => { window.MobileRouter.navigate("#/assistant/chat"); });
await expect(page.locator("#m-title")).toHaveText("复盘助手");
await expect(page.locator("#m-chat-input")).toBeVisible();
await expect(page.locator(".m-chat-presets")).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
});
test("empty dashboard shows empty state instead of a bare table", async ({ page }) => {
await mockMobileApi(page, { emptyDashboard: true });
await openMobile(page);
await navigateToFeature(page, "market/limit-up");
await expect(page.locator(".m-state")).toBeVisible();
await expect(page.locator(".m-state p")).toContainText("暂无相关数据");
});
test("dashboard failure shows error state with retry", async ({ page }) => {
await mockMobileApi(page, { failDashboard: true });
await openMobile(page);
await navigateToFeature(page, "market/limit-up");
await expect(page.locator(".m-state--error")).toBeVisible();
await expect(page.locator(".m-btn-retry")).toBeVisible();
});
test("landscape keeps pages usable and returns to portrait", async ({ page }) => {
await mockMobileApi(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/m/");
await expect(page.locator("#m-tabbar")).toBeVisible();
await page.setViewportSize({ width: 844, height: 390 });
await page.evaluate(() => { window.MobileRouter.navigate("#/feature/market/sentiment"); });
await page.waitForTimeout(150);
await expect(page.locator("#m-title")).toHaveText("情绪周期");
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
await expect(page.locator("#m-tabbar")).toBeVisible();
await page.evaluate(() => { window.MobileRouter.navigate("#/feature/market/limit-up"); });
await page.waitForTimeout(150);
await expect(page.locator(".m-table")).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator("#m-tabbar")).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
});