From 1fd69b1dcf92a87eeb53b65105690051a71937fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=98?= <4846a27a-ba87-49c9-939d-7275f63efcc7@agents.multica.local> Date: Tue, 25 Aug 2026 09:53:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=BD=E5=B7=A5(HEL-129):=20P5=20=E6=94=B6?= =?UTF-8?q?=E5=AE=98=E6=89=93=E7=A3=A8=E2=80=94=E2=80=94=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?5=E5=A4=84=E9=99=88=E6=97=A7=E6=A1=8C=E9=9D=A2e2e=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E8=A2=AB=E6=89=8B=E6=9C=BA=E5=88=86=E6=B5=81=E5=8A=AB?= =?UTF-8?q?=E6=8C=81=EF=BC=8C=E6=96=B0=E5=A2=9E=E6=89=8B=E6=9C=BA=E7=AB=AF?= =?UTF-8?q?=E5=85=A8=E9=A1=B5=E9=9D=A2=E5=9B=9E=E5=BD=92=E5=A5=97=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- app/tests/e2e/app-shell.spec.js | 11 +- app/tests/e2e/mobile-app.spec.js | 346 +++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 app/tests/e2e/mobile-app.spec.js diff --git a/app/tests/e2e/app-shell.spec.js b/app/tests/e2e/app-shell.spec.js index 8364d6f..9ceaa8b 100644 --- a/app/tests/e2e/app-shell.spec.js +++ b/app/tests/e2e/app-shell.spec.js @@ -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 }) => { await page.setViewportSize({ width: 375, height: 812 }); 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); expect(overflow).toBeLessThanOrEqual(1); 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 }) => { await page.setViewportSize({ width: 430, height: 932 }); 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)); for (const viewport of [ { width: 430, height: 932 }, @@ -2730,7 +2730,7 @@ for (const viewport of [ test(`member workflows fit a mobile ${viewport.name} viewport`, async ({ page }) => { await page.setViewportSize({ width: viewport.width, height: viewport.height }); await mockApplication(page, session("user", true)); - await page.goto("/index.html"); + await page.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); @@ -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 }) => { await page.setViewportSize({ width: 375, height: 812 }); 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(); 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) { 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); for (const theme of ["day", "night"]) { diff --git a/app/tests/e2e/mobile-app.spec.js b/app/tests/e2e/mobile-app.spec.js new file mode 100644 index 0000000..2a5869d --- /dev/null +++ b/app/tests/e2e/mobile-app.spec.js @@ -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); +});