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", }, }, }; } async function mockApplication(page, authSession = session()) { 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") payload = dashboard; else if (url.pathname === "/api/stock/002141/preview") { payload = { meta: { trade_date: "2026-07-23", realtime: false, 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: [], }; } else if (url.pathname === "/api/watchlist" || url.pathname === "/api/notes") payload = { items: [] }; 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: [], sectors: [], themes: [], indices: [] } }; 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/screener/setup") { payload = { trade_date: "20260722", regime: { id: "repair", label: "修复", confidence: 70, reason: "测试", evidence: [] }, regimes: [{ id: "repair", label: "修复" }], factor_data: { ready: false, date_count: 0 }, llm: { configured: true }, strategies: [], }; } else if (url.pathname === "/api/screener/tracking") { payload = { batches: [{ selection_date: "20260721", strategy_name: "Repair confirmation", items: [{ 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 }, }; } else if (url.pathname === "/api/mentors/setup") payload = { trade_date: "20260722", mentors: [] }; else if (url.pathname === "/api/heaven/setup") { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "测试环境不加载问天数据" }) }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(payload) }); }); } test("admin shell opens every primary workspace and global search", async ({ page }) => { await mockApplication(page); await page.goto("/index.html"); await expect(page.locator("#authGate")).toBeHidden(); await expect(page.locator("#settingsButton")).toBeVisible(); await expect(page.locator("#syncButton")).toBeVisible(); await page.locator("#alertButton").click(); await expect(page.locator("#alertsDialog")).toBeVisible(); await page.locator("#closeAlertsDialog").click(); await page.locator("#assistantButton").click(); await expect(page.locator("#assistantDialog")).toBeVisible(); await page.locator("#closeAssistantDialog").click(); const views = [ "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView", "performanceView", "ladderView", "rotationView", "dragonView", "screenerView", "mentorView", "heavenView", "reviewWorkspaceView", ]; for (const view of views) { await page.locator(`[data-view="${view}"]`).first().click(); await expect(page.locator(`#${view}`)).toHaveClass(/active-view/); } await page.keyboard.press("Control+K"); await expect(page.locator("#globalSearchDialog")).toBeVisible(); await expect(page.locator("#globalSearchInput")).toBeFocused(); }); 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("#screenerRunButton")).toBeDisabled(); await page.locator("#assistantButton").click(); await expect(page.locator("#settingsDialog")).toBeVisible(); await expect(page.locator("#membershipAccessNotice")).toHaveText("复盘助手仅对会员开放"); await expect(page.locator("#membershipAccessNotice")).toBeVisible(); }); 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"); }); test("mobile shell stays within the viewport", async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }); await mockApplication(page, session("user", true)); await page.goto("/index.html"); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); expect(overflow).toBeLessThanOrEqual(1); await expect(page.locator("#globalSearchButton")).toBeVisible(); }); test("new review workflows render account-scoped records", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.locator('[data-view="screenerView"]').first().click(); 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("#tradeLogTableBody tr")).toHaveCount(1); await expect(page.locator("#tradeLogEmpty")).toBeHidden(); 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("#assistantMessages .assistant-message")).toHaveCount(1); await expect(page.locator("#assistantQuestion")).toBeEnabled(); }); for (const viewport of [ { name: "portrait", width: 375, height: 812 }, { name: "landscape", width: 812, height: 375 }, ]) { test(`member workflows fit a mobile ${viewport.name} viewport`, async ({ page }) => { await page.setViewportSize({ width: viewport.width, height: viewport.height }); await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.locator('[data-view="reviewWorkspaceView"]').first().click(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); expect(overflow).toBeLessThanOrEqual(1); await expect(page.locator("#tradeLogForm")).toBeVisible(); }); } 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"); });