const { test, expect } = require("@playwright/test"); // 手机端(/m/)全页面回归:P5 收官打磨 + HEL-233 系统管理恢复。 // 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手、系统管理 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 (path === "/api/account/status") { payload = { birth_profile_configured: true, birth_profile: { birth_datetime: "1990-01-15T08:30", gender: "male" }, llm_access: { daily_limit: 50, used_today: 3, remaining_calls: 47, membership: { active: true, subscribed: true, is_admin: auth.user.role === "admin", remaining_days: 20, expires_at: "2026-09-18", plan: "会员", }, }, }; } else if (path === "/api/admin/settings") { payload = { data: { configured: true, snapshot_dates: 12, background_refresh_enabled: true, ifind: { configured: false } }, llm: { models: [{ id: "model-1", name: "主模型", base_url: "https://api.openai.com/v1", model: "gpt-4.1", configured: true }], primary_model_id: "model-1", fallback_model_id: "" }, membership: { member_daily_limit: 50 }, users: [{ id: 2, username: "normal_user", role: "user", membership_subscribed: true, membership_status: "active", membership_expires_at: "2026-09-18", used_today: 3 }], }; } 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", "提醒中心"], ]; const SYSTEM_PAGES = [ ["system/profile", "账号资料"], ["system/password", "修改密码"], ["system/membership", "会员状态"], ["system/admin", "系统设置"], ["system/members", "会员管理"], ]; const PLACEHOLDER_COPY = "该功能页将在后续批次实现"; 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("system management pages render real content instead of placeholders", async ({ page }) => { await mockMobileApi(page); await openMobile(page); for (const [key, label] of SYSTEM_PAGES) { await navigateToFeature(page, key); await expect(page.locator("#m-title")).toHaveText(label); await expect(page.locator(".m-placeholder")).toHaveCount(0); await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY); await expect(page.locator("[data-system-page], [data-system-admin-panel]").first()).toBeVisible(); expect(await measureOverflow(page)).toBeLessThanOrEqual(1); } await navigateToFeature(page, "system/profile"); await expect(page.locator("#m-sys-birth-date")).toBeVisible(); await expect(page.locator("[data-system-switch]")).toBeVisible(); await navigateToFeature(page, "system/password"); await expect(page.locator("#m-sys-password-current")).toBeVisible(); await navigateToFeature(page, "system/membership"); await expect(page.locator(".m-sys-grid")).toBeVisible(); await navigateToFeature(page, "system/admin"); await expect(page.locator("#m-sys-token")).toBeVisible(); await navigateToFeature(page, "system/members"); await expect(page.locator("#m-sys-member-limit")).toBeVisible(); }); test("non-admin cannot open system admin pages as placeholders", async ({ page }) => { await mockMobileApi(page, { auth: authSession("user", true) }); await openMobile(page); await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); }); await expect(page.locator(".m-hub-grid")).toBeVisible(); await expect(page.locator('.m-grid-item[data-route="#/feature/system/admin"]')).toHaveCount(0); await expect(page.locator('.m-grid-item[data-route="#/feature/system/members"]')).toHaveCount(0); await navigateToFeature(page, "system/admin"); await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY); await expect(page.locator("[data-system-page='forbidden']")).toBeVisible(); }); 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); });