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 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()) { 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/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: 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: "" }), 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) }); }); } 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")).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"); }); 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 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); if (mode === "trend") { const completionMs = await page.evaluate(async () => { const started = performance.now(); await heavenReadingAnimation.complete(); return performance.now() - started; }); expect(completionMs).toBeLessThan(1000); await expect(canvas).toHaveAttribute("data-running", "false"); } await page.locator("#closeHeavenReadingDialog").click(); await expect(canvas).toHaveAttribute("data-running", "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.evaluate(() => startHeartBreathing()); const timing = await page.evaluate(() => ({ remaining: state.heartBreathingEndsAt - Date.now(), incenseDuration: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration, incenseDelay: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDelay, rippleDuration: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDuration, rippleDelay: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDelay, rippleAnimation: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationName, rippleKeyframes: document.querySelector(".heart-breath-ripple span").getAnimations()[0].effect.getKeyframes(), })); expect(timing.remaining).toBeGreaterThan(45_000); expect(timing.remaining).toBeLessThanOrEqual(46_000); expect(timing.incenseDuration).toBe("45s"); expect(timing.incenseDelay).toBe("1s"); expect(timing.rippleDuration).toBe("9s"); expect(timing.rippleDelay).toBe("1s"); expect(timing.rippleAnimation).toContain("heart-breath-ripple"); expect(timing.rippleKeyframes.at(-1).transform).toContain("0.62"); 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 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("呼"); }); 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 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.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(); }); 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("#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 .heaven-toolbar h2").evaluate((element) => getComputedStyle(element).fontSize)); expect(titleSize).toBeLessThanOrEqual(22); 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(920); 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"); 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(); expect(heartControls.width).toBeLessThanOrEqual(80); 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("#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')).toHaveText("5/6"); 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('[data-mentor-grade="B"]').click(); 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("#activeMentorBadges")).toContainText("B · 多源整理"); await expect(page.locator("#activeMentorEvidence")).toHaveText("公开访谈与多源材料"); }); 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-pin="source-c"]').click(); await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-c"); await page.locator('[data-mentor-pin="source-b"]').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); }); test("mobile mentor directory opens as a searchable selector and hides private mentors", async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }); await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.locator('[data-view="mentorView"]').first().click(); await expect(page.locator("#mentorDirectoryToggle")).toBeVisible(); await page.locator("#mentorDirectoryToggle").click(); await expect(page.locator("#mentorView .mentor-sidebar")).toHaveClass(/is-open/); await expect(page.locator("#mentorList .mentor-option")).toHaveCount(21); await expect(page.locator('#mentorList [data-mentor-id="private-owner"]')).toHaveCount(0); await page.locator('#mentorList [data-mentor-id="source-c"]').click(); await expect(page.locator("#mentorView .mentor-sidebar")).not.toHaveClass(/is-open/); await expect(page.locator("#mobileActiveMentorName")).toHaveText("推演老师"); expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1); });