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 sentimentHistoryPayload(days = 20) { const phases = ["冰点", "修复", "发酵", "高潮", "分化", "退潮"]; const rows = Array.from({ length: days }, (_, index) => { const score = 28 + ((index * 7) % 55); return { trade_date: `2026-08-${String(index + 1).padStart(2, "0")}`, score, label: "情绪观察", phase: phases[index % phases.length], direction: index % 2 ? "升温" : "降温", day_change: index % 2 ? 3.2 : -2.1, seal_rate: 71.5, limit_up_count: 40 + index, first_board_count: 18, second_board_count: 8, three_plus_count: 4, max_height: 5, broken_count: 12, limit_down_count: 3, previous_limit_count: 38, previous_positive_count: 22, previous_positive_rate: 57.9, average_previous_change: 1.2, normalization: "固定锚点", components: { breadth: { label: "市场宽度", score: 55.8, weight: 20, summary: "红盘家数回升" }, limit: { label: "涨停连板", score: 79.6, weight: 25, summary: "连板生态改善" }, profit: { label: "赚钱效应", score: 67.0, weight: 30, summary: "昨日反馈尚可" }, ladder: { label: "涨幅结构", score: 81.5, weight: 15, summary: "高度仍在扩张" }, amount: { label: "成交活跃度", score: 46.1, weight: 10, summary: "量能略低于均值" }, }, }; }); return { available_days: days, rows }; } async function renderSentimentFixture(page, days = 20) { await page.evaluate((payload) => { state.sentimentHistory = payload; renderSentimentHistory(); }, sentimentHistoryPayload(days)); await page.locator('[data-view="sentimentCycleView"]').first().click(); await page.waitForTimeout(900); } function readSentimentLayout() { const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect(); const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect(); const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect(); const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect(); const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect(); const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect(); const table = document.querySelector(".sentiment-history-frame").getBoundingClientRect(); const small = document.querySelector(".sentiment-component-item small"); return { topDelta: Math.abs(trend.top - summary.top), bottomDelta: Math.abs(trend.bottom - components.bottom), analysisHeight: analysis.height, chartHeight: chart.height, detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom) - 0.5, tableVisible: table.top < window.innerHeight && table.bottom > detail.bottom, smallVisible: small ? getComputedStyle(small).display !== "none" : false, overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, }; } function waitForApplicationRuntime(page) { return expect(page.locator("body")).toHaveAttribute("data-runtime-ready", "true"); } function installApplicationNavigation(page) { if (page.__xiaobaiNavigationWrapped) return; page.__xiaobaiNavigationWrapped = true; const goto = page.goto.bind(page); const reload = page.reload.bind(page); page.goto = async (...args) => { const response = await goto(...args); await waitForApplicationRuntime(page); return response; }; page.reload = async (...args) => { const response = await reload(...args); await waitForApplicationRuntime(page); return response; }; } 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(), options = {}) { installApplicationNavigation(page); let screenerTracking = { batches: [{ run_id: 44, selection_date: "20260721", strategy_name: "Repair confirmation", items: [{ id: 9, 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 }, }; 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") { options.dashboardRequests = (options.dashboardRequests || 0) + 1; options.dashboardTradeDates ||= []; options.dashboardTradeDates.push(url.searchParams.get("trade_date")); if (options.dashboardDelay) { await new Promise((resolve) => setTimeout(resolve, options.dashboardDelay)); } if (options.echoDashboardDate) { const requestedDate = url.searchParams.get("trade_date"); payload = { ...dashboard, meta: { ...dashboard.meta, trade_date: requestedDate, requested_date: requestedDate } }; } else payload = dashboard; } else if (url.pathname === "/api/stock/002141/preview") { if (options.previewDelay) { await new Promise((resolve) => setTimeout(resolve, options.previewDelay)); } payload = { meta: { trade_date: "2026-07-23", intraday_trade_date: "2026-07-24", realtime: true, 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: [ { date: "2026-07-24", time: "09:30", open: 10.20, high: 10.24, low: 10.18, close: 10.22, volume: 100, average: 10.22 }, { date: "2026-07-24", time: "09:31", open: 10.22, high: 10.30, low: 10.21, close: 10.28, volume: 130, average: 10.25 }, { date: "2026-07-24", time: "09:32", open: 10.28, high: 10.29, low: 10.20, close: 10.23, volume: 90, average: 10.24 }, { date: "2026-07-24", time: "09:33", open: 10.23, high: 10.34, low: 10.22, close: 10.32, volume: 160, average: 10.27 }, { date: "2026-07-24", time: "09:34", open: 10.32, high: 10.36, low: 10.29, close: 10.34, volume: 120, average: 10.28 }, ], }; } else if (url.pathname === "/api/stock/002141") { payload = { meta: { trade_date: "2026-07-23", realtime: false }, 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 }, ], moneyflow: {}, notes: [], }; } else if (url.pathname === "/api/search/detail") { const theme = url.searchParams.get("type") === "theme"; payload = theme ? { meta: { trade_date: "2026-07-23", realtime: false }, entity: { id: "885728.TI", code: "885728.TI", name: "人工智能", type: "theme", type_label: "题材", value: 1280, change: 2.2 }, series: [ { trade_date: "2026-07-22", open: 1220, high: 1260, low: 1210, close: 1250, volume: 1000 }, { trade_date: "2026-07-23", open: 1255, high: 1290, low: 1248, close: 1280, volume: 1200 }, ], metrics: [], } : { meta: { trade_date: "2026-07-23", realtime: false }, entity: { id: "000001.SH", code: "000001.SH", name: "上证指数", type: "index", type_label: "指数", value: 3800, change: 0.5 }, series: [ { trade_date: "2026-07-22", open: 3750, high: 3790, low: 3740, close: 3780, volume: 1000 }, { trade_date: "2026-07-23", open: 3782, high: 3810, low: 3770, close: 3800, volume: 1200 }, ], metrics: [], }; } else if (url.pathname === "/api/chart/intraday") { payload = { meta: { trade_date: "2026-07-24", previous_close: 10.1 }, entity: { id: url.searchParams.get("id"), type: url.searchParams.get("type") }, points: [ { date: "2026-07-24", time: "09:30", open: 10.10, high: 10.18, low: 10.08, close: 10.15, volume: 100, average: 10.15 }, { date: "2026-07-24", time: "09:31", open: 10.15, high: 10.24, low: 10.14, close: 10.22, volume: 130, average: 10.18 }, { date: "2026-07-24", time: "09:32", open: 10.22, high: 10.23, low: 10.16, close: 10.18, volume: 90, average: 10.18 }, ], }; } else if (url.pathname === "/api/watchlist") { payload = { items: [{ code: "000002", name: "Watch Stock", sector: "Bank", color: "red", change: 1.86, return_5d: 8.92, attention_score: 72.4, remark: "观察承接,不追高", market_date: "20260722", }] }; } else if (url.pathname === "/api/notes") { payload = { items: [{ id: 12, code: "", stock_name: "", trade_date: "20260722", summary: "缩量修复,主线仍待确认", content: "做对了等待确认。", plan: "只做有承接的核心。", }] }; } 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: [{ id: "002141", code: "002141", name: "Test Stock", type: "stock", industry: "Test Sector" }], sectors: [], themes: [], indices: [] } }; else if (url.pathname === "/api/dragon-tiger/profiles") { payload = { meta: { status: "success", source: "tushare", cached: true }, summary: { profile_count: 3, described_count: 2, organization_count: 4 }, profiles: [ { id: "hot-money-profile-1", name: "赵老哥", description: "聚焦市场核心标的。", organizations: ["华泰证券浙江分公司", "银河证券绍兴"], organization_count: 2 }, { id: "hot-money-profile-2", name: "炒股养家", description: "重视情绪与风险收益比。", organizations: ["华鑫证券上海宛平南路"], organization_count: 1 }, { id: "hot-money-profile-3", name: "作手新一", description: "", organizations: ["国泰海通证券南京太平南路"], organization_count: 1 }, ], }; } 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/rotation/members") { payload = { meta: { trade_date: "20260724", sector_name: url.searchParams.get("sector") || "电网设备", member_count: 3, quoted_count: 2 }, rows: [ { code: "002879", name: "长缆科技", change: 4.8, open: 18.21, close: 19.06, amount_billion: 19.6, quoted: true }, { code: "603221", name: "爱丽家居", change: 1.2, open: 13.05, close: 13.22, amount_billion: 8.7, quoted: true }, { code: "000001", name: "停牌样本", change: null, open: null, close: null, amount_billion: null, quoted: false }, ], }; } else if (url.pathname === "/api/auction") { payload = { meta: { trade_date: "2026-07-22", carried_forward: false, phase: "finalized", available: true, actionable: false }, summary: { stock_count: 3, candidate_count: 1, focus_count: 1, one_price_count: 1, up_count: 2, down_count: 1, limit_open_count: 1, strong_open_count: 2, median_change: 1.2, amount_billion: 2.5, amount_change_previous: 12.5, amount_change_5d: 8.2 }, expectations: { "超预期": 1, "符合预期": 0, "低于预期": 0 }, candidate_meta: { baseline_date: "2026-07-21" }, themes: { carry: [{ name: "Test Sector", status: "强承接", prior_limit_count: 2, leader: "Test Stock", matched_count: 1, median_change: 4.2, positive_rate: 100, amount_million: 15 }], new_themes: [{ name: "人工智能", stock_count: 2, median_change: 3.5, amount_million: 26, leaders: ["Test Stock"] }], }, amount_history: [ { trade_date: "2026-07-21", amount_billion: 2.2, stock_count: 2 }, { trade_date: "2026-07-22", amount_billion: 2.5, stock_count: 2 }, ], news_feedback: { available: false, message: "隔夜消息反馈暂不可用", detail: "待稳定的新闻与公告数据接入后开放" }, focus_rows: [ { code: "002141", name: "Test Stock", sector: "Test Sector", change: 4.2, price: 10.2, amount_million: 15, volume_ratio: 1.8, turnover_rate: 0.12, source_label: "昨日涨停 · 同花顺热榜", expectation: "超预期", expected_change: 2.2, attention_score: 88.5, core_tags: ["人气前5"], expectation_reason: "昨日首板;竞价涨幅高于预期中枢2.0个百分点,量比1.80" }, ], one_price_rows: [ { code: "000001", name: "Limit Stock", sector: "Test Sector", change: 10, price: 11, amount_million: 8, volume_ratio: 3.2, source_label: "昨日涨停", prior_streak: 3, core_tags: ["三板以上"], is_market_core: true, is_one_price: true }, ], watchlist_rows: [ { code: "000002", name: "Watch Stock", sector: "Bank", change: -1.2, price: 9.88, amount_million: 3, volume_ratio: 0.9, expectation: "符合预期", expected_change: 0, attention_score: 32.5, core_tags: [], expectation_reason: "自选观察;竞价反馈接近个人观察基准", is_watchlist: true, available: true }, ], watchlist_missing_count: 0, rows: [ { code: "002141", name: "Test Stock", sector: "Test Sector", change: 4.2, price: 10.2, amount_million: 15, volume_ratio: 1.8, turnover_rate: 0.12, source_label: "昨日涨停 · 同花顺热榜", expectation: "超预期", expected_change: 2.2, attention_score: 88.5, core_tags: ["人气前5"], expectation_reason: "昨日首板;竞价涨幅高于预期中枢2.0个百分点,量比1.80" }, ], }; } else if (url.pathname === "/api/themes") { payload = { meta: { trade_date: "2026-07-22", carried_forward: false }, summary: { theme_count: 1, quoted_count: 1, up_count: 1, down_count: 0, hot_count: 1 }, items: [{ code: "885728.TI", name: "人工智能", member_count: 1, change: 2.2, turnover_rate: 3.1, hot_rank: 1, has_quote: true }], }; } else if (url.pathname === "/api/themes/detail") { payload = { meta: { trade_date: "2026-07-22" }, theme: { code: "885728.TI", name: "人工智能", member_count: 1, change: 2.2, turnover_rate: 3.1 }, summary: { member_count: 1, quoted_count: 1, up_count: 1, down_count: 0 }, series: [ { trade_date: "2026-07-21", open: 100, high: 104, low: 99, close: 103, change: 3, volume: 1000 }, { trade_date: "2026-07-22", open: 103, high: 106, low: 102, close: 105, change: 1.94, volume: 1200 }, ], members: [{ code: "002141", name: "Test Stock", change: 2.4, price: 10.8, amount_billion: 3.2, has_quote: true }], }; } else if (url.pathname === "/api/popularity") { const hot = { rank: 1, code: "002141", ts_code: "002141.SZ", name: "Test Stock", change: 2.4, price: 10.8, ths_rank: 1, dc_rank: 2, rank_change: 3, concepts: ["人工智能"], dual_source: true }; payload = { meta: { trade_date: "2026-07-22", carried_forward: false }, summary: { ths_count: 1, dc_count: 1, dual_count: 1 }, combined: [hot], ths: [{ ...hot, rank: 1 }], dc: [{ ...hot, rank: 2 }] }; } else if (url.pathname === "/api/screener/setup") { options.screenerSetupRequests = (options.screenerSetupRequests || 0) + 1; const factorFields = [ ["amount_billion", "成交额(亿元)"], ["above_ma20", "站上20日线"], ["relative_strength", "相对强度"], ["sector_strength", "板块强度"], ["volume_ratio_5d", "5日量比"], ["volatility_10d", "10日波动率"], ["dividend_yield_ttm", "股息率TTM"], ].map(([id, label]) => ({ id, label })); payload = { trade_date: "20260722", regime: { id: "repair", label: "修复", confidence: 70, reason: "测试", evidence: [] }, regimes: [{ id: "repair", label: "修复" }], factor_fields: factorFields, factor_groups: [{ name: "行情与质量", fields: factorFields }], operators: [">", ">=", "<", "<=", "==", "between"], factor_data: { ready: true, date_count: 45, start_date: "20260518", end_date: "20260722", health: { market: true, auction: true, valuation: true, fundamental: true, dividend_history: true }, }, llm: { configured: true }, strategies: [ { id: 1, name: "修复确认", description: "保留原有智能策略流程", regimes: ["repair"], builtin: true, data_ready: true, missing_data: [], formula: { meta: { library: "smart" }, universe: { exclude_st: true, listed_days_min: 120 }, filters: [], score: [{ field: "relative_strength", weight: 1, direction: "desc" }], limit: 15, min_score: 0.5, }, }, { id: 2, name: "连续分红质量", description: "持续分红、估值与流动性共同约束。", regimes: ["repair"], builtin: true, data_ready: true, missing_data: [], formula: { meta: { library: "curated", category: "红利价值", quality: "A", frequency: "月度", risk: "中低", data_group: "估值与财务" }, universe: { exclude_st: true, listed_days_min: 720 }, filters: [{ field: "dividend_yield_ttm", op: ">=", value: 2 }], score: [{ field: "dividend_yield_ttm", weight: 1, direction: "desc" }], limit: 20, min_score: 0.5, }, }, ], }; if (options.additionalScreenerRegimes) { payload.regimes.push(...options.additionalScreenerRegimes); } if (options.additionalScreenerStrategies) { payload.strategies.push(...options.additionalScreenerStrategies); } if (options.latestScreenerResults) { payload.latest_results = options.latestScreenerResults; payload.latest_result = options.latestScreenerResults.smart || null; } if (options.recentScreenerResults) { payload.recent_results = options.recentScreenerResults; } if (options.screenerPublishedRuns) { for (const strategy of payload.strategies) { const publishedRun = options.screenerPublishedRuns[strategy.name]; if (publishedRun) strategy.published_run = publishedRun; } } if (options.screenerSetupOverride) { payload = { ...payload, ...options.screenerSetupOverride }; } } else if (url.pathname === "/api/screener/run") { const body = route.request().postDataJSON(); options.screenerRunBodies = [...(options.screenerRunBodies || []), body]; const runResult = options.screenerRunResult?.(body); payload = { result: runResult || options.latestScreenerResults?.[body.mode] || { meta: { run_id: 99, trade_date: "20260722", regime: body.regime, strategy_name: body.strategy_name, mode: body.mode, }, candidates: [], disclaimer: "历史统计不代表未来收益", backtest: null, }, }; if (options.recentScreenerResults) { options.recentScreenerResults.unshift(payload.result); } } else if (url.pathname === "/api/screener/tracking") { if (route.request().method() === "POST") { const body = route.request().postDataJSON(); screenerTracking = { batches: [...screenerTracking.batches, { run_id: body.run_id, selection_date: "20260722", strategy_name: "Manual strategy", items: [{ id: 10, code: body.code, name: "Manual Stock", entry_price: 12, observed_days: 0, status: "等待 T+1" }], }], summary: { ...screenerTracking.summary, total: screenerTracking.summary.total + 1 }, }; payload = { ok: true, tracking: screenerTracking }; } else payload = screenerTracking; } else if (/^\/api\/screener\/tracking\/\d+$/.test(url.pathname)) { const trackId = Number(url.pathname.split("/").at(-1)); screenerTracking = { batches: screenerTracking.batches.map((batch) => ({ ...batch, items: batch.items.filter((item) => item.id !== trackId), })).filter((batch) => batch.items.length), summary: { ...screenerTracking.summary, total: Math.max(0, screenerTracking.summary.total - 1) }, }; payload = { ok: true, deleted: true, tracking: screenerTracking }; } 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: options.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: "", follow_ups: ["哪些信号代表确认?", "这个判断在什么情况下失效?", "空仓时应该先观察什么?"], }), 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) }); }); } async function openHeaderCommandMenu(page) { const menu = page.locator("#headerCommandGroup"); if (await menu.isVisible()) return; await page.locator("#headerMenuButton").click(); await expect(menu).toBeVisible(); } function namedSession(username, role = "admin", subscribed = true) { const auth = session(role, subscribed); auth.user.username = username; return auth; } async function setColorTheme(page, theme) { const wanted = theme === "night" ? "dark" : "light"; const current = await page.locator("html").getAttribute("data-theme"); if (current === wanted) return; await page.locator("#themeToggle").click(); await expect(page.locator("html")).toHaveAttribute("data-theme", wanted); } function measureAccountName() { const name = document.querySelector("#accountName"); const button = document.querySelector("#accountButton"); const badges = document.querySelector("#accountRoleBadges"); const adminLabel = document.querySelector("#accountAdminBadge > span"); const vipLabel = document.querySelector("#accountVipLabel"); const box = (node) => node ? node.getBoundingClientRect() : null; const visible = (node) => { if (!node || node.hidden) return false; const style = getComputedStyle(node); if (style.display === "none" || style.visibility === "hidden") return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; const buttonBox = box(button); const badgeBox = box(badges); return { text: name ? String(name.textContent || "") : "", clientWidth: name ? name.clientWidth : 0, scrollWidth: name ? name.scrollWidth : 0, fits: name ? name.clientWidth >= name.scrollWidth : false, buttonFits: button ? button.clientWidth >= button.scrollWidth : false, stacked: Boolean(badgeBox && buttonBox && buttonBox.top >= badgeBox.bottom - 1), adminLabelVisible: visible(adminLabel), vipLabelVisible: visible(vipLabel), documentOverflow: document.documentElement.scrollWidth - window.innerWidth, }; } 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 openHeaderCommandMenu(page); await expect(page.locator("#settingsButton")).toBeVisible(); await expect(page.locator("#syncButton")).toBeVisible(); await page.keyboard.press("Escape"); 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 = [ "auctionView", "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView", "performanceView", "ladderView", "rotationView", "themeLibraryView", "popularityView", "dragonView", "screenerView", "heavenView", "reviewWorkspaceView", "mentorView", ]; for (const view of views) { await page.locator(`[data-view="${view}"]`).first().click(); await expect(page.locator(`#${view}`)).toHaveClass(/active-view/); await expect(page.locator(".module-tab.active")).toHaveCount(1); } await page.keyboard.press("Control+K"); await expect(page.locator("#globalSearchDialog")).toBeVisible(); await expect(page.locator("#globalSearchInput")).toBeFocused(); }); test("fresh visits default to the latest date and sentiment cycle", async ({ page }) => { const options = { echoDashboardDate: true }; await mockApplication(page, session("admin", true), options); await page.goto("/index.html?date=2026-07-28"); const today = await page.evaluate(() => todayString()); await expect(page.locator("#tradeDate")).toHaveValue(today); await expect(page.locator("#sentimentCycleView")).toHaveClass(/active-view/); await expect(page.locator('[data-view="sentimentCycleView"]')).toHaveClass(/active/); expect(options.dashboardTradeDates.at(-1)).toBe(today); expect(new URL(page.url()).searchParams.has("date")).toBe(false); await page.evaluate(() => { const input = document.querySelector("#tradeDate"); input.value = "2026-07-28"; input.dispatchEvent(new Event("change", { bubbles: true })); }); await expect.poll(() => options.dashboardTradeDates.at(-1)).toBe("2026-07-28"); expect(new URL(page.url()).searchParams.has("date")).toBe(false); }); test("every primary workspace shares the canonical desktop shell geometry", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await mockApplication(page, session("admin", true)); await page.goto("/index.html"); const views = [ "auctionView", "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView", "performanceView", "ladderView", "rotationView", "themeLibraryView", "popularityView", "dragonView", "screenerView", "heavenView", "reviewWorkspaceView", ]; let reference = null; for (const view of views) { await page.locator(`[data-view="${view}"]`).first().click(); const activeView = page.locator(`#${view}`); await expect(activeView).not.toHaveClass(/view-entering/); const box = await activeView.boundingBox(); expect(box).not.toBeNull(); reference ||= { x: box.x, y: box.y, width: box.width }; expect(Math.abs(box.x - reference.x)).toBeLessThanOrEqual(1); expect(Math.abs(box.y - reference.y)).toBeLessThanOrEqual(1); expect(Math.abs(box.width - reference.width)).toBeLessThanOrEqual(1); } }); test("manual refresh stays in place without reopening the full-page loader", async ({ page }) => { const options = { dashboardDelay: 350 }; await mockApplication(page, session(), options); await page.goto("/index.html"); await expect(page.locator("#loadingOverlay")).toBeHidden(); await openHeaderCommandMenu(page); await page.locator("#refreshButton").click(); await expect(page.locator("#refreshButton")).toBeDisabled(); await expect(page.locator("#loadingOverlay")).toBeHidden(); await expect(page.locator("#statusText")).toContainText("刷新"); await expect(page.locator("#refreshButton")).toBeEnabled(); expect(options.dashboardRequests).toBe(2); }); test("night mode covers the application shell and persists across reloads", async ({ page }) => { await mockApplication(page, session("admin", true)); await page.addInitScript(() => { if (sessionStorage.getItem("themeTestReady")) return; localStorage.removeItem("xiaobaiTheme"); sessionStorage.setItem("themeTestReady", "1"); }); await page.goto("/index.html"); await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "false"); await page.locator("#themeToggle").click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到日间模式"); await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "true"); const darkSurfaces = await page.evaluate(() => { const color = (selector) => getComputedStyle(document.querySelector(selector)).backgroundColor; return { body: color("body"), sidebar: color(".module-nav"), topbar: color(".app-header"), tableHead: color("#limitTable thead th"), }; }); expect(new Set(Object.values(darkSurfaces)).has("rgb(255, 255, 255)")).toBe(false); await page.reload(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "true"); await page.keyboard.press("Control+K"); await expect(page.locator("#globalSearchDialog")).toBeVisible(); expect(await page.locator("#globalSearchDialog").evaluate((dialog) => getComputedStyle(dialog).backgroundColor)).not.toBe("rgb(255, 255, 255)"); await page.locator("#closeGlobalSearch").click(); await page.locator("#themeToggle").click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到夜间模式"); }); test("collapsed overview and sentiment layout keep a single current reading", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await mockApplication(page, session("user", true)); await page.goto("/index.html"); await expect(page.locator("#sentimentGauge")).toBeHidden(); await page.evaluate(() => { state.sentimentHistory = { available_days: 20, rows: [{ trade_date: "2026-07-22", score: 32, label: "情绪偏弱", phase: "退潮", direction: "降温", day_change: -12, seal_rate: 65, limit_up_count: 32, first_board_count: 20, second_board_count: 6, three_plus_count: 3, max_height: 4, broken_count: 17, limit_down_count: 25, previous_limit_count: 40, previous_positive_count: 12, previous_positive_rate: 30, average_previous_change: -1.2, normalization: "固定锚点", components: { breadth: { label: "市场宽度", score: 28, weight: 20, summary: "红盘家数偏少" }, limit: { label: "涨停生态", score: 42, weight: 25, summary: "封板率仍需确认" }, profit: { label: "赚钱效应", score: 31, weight: 30, summary: "昨日反馈偏弱" }, ladder: { label: "连板结构", score: 38, weight: 15, summary: "高度仍在压缩" }, amount: { label: "成交活跃度", score: 25, weight: 10, summary: "量能低于均值" }, }, }], }; renderSentimentHistory(); }); await page.locator('[data-view="sentimentCycleView"]').first().click(); await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0); await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。"); const alignment = await page.evaluate(() => { const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect(); const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect(); const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect(); const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect(); const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect(); const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect(); const label = document.querySelector(".sentiment-block .metric-label"); const status = document.querySelector(".sentiment-block .sentiment-text"); const labelStyle = getComputedStyle(document.querySelector(".sentiment-block .metric-label")); const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text")); return { columnsAligned: Math.abs(trend.top - summary.top) < 1, bottomsAligned: Math.abs(trend.bottom - components.bottom) < 1, analysisHeight: analysis.height, railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom, detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom), chartHeight: chart.height, sameType: labelStyle.fontSize === statusStyle.fontSize && labelStyle.fontWeight === statusStyle.fontWeight && labelStyle.lineHeight === statusStyle.lineHeight, sameBaseline: Math.abs(label.getBoundingClientRect().y - status.getBoundingClientRect().y) < 0.1, noStatusOffset: statusStyle.marginTop === "0px", }; }); expect(alignment.columnsAligned).toBe(true); expect(alignment.bottomsAligned).toBe(true); expect(Math.abs(alignment.analysisHeight - 600)).toBeLessThanOrEqual(1); expect(alignment.railAligned).toBe(true); expect(alignment.detailAfterAnalysis).toBe(true); expect(alignment.chartHeight).toBeGreaterThanOrEqual(340); expect(alignment.sameType).toBe(true); expect(alignment.sameBaseline).toBe(true); expect(alignment.noStatusOffset).toBe(true); const pageFrames = {}; for (const [view, headSelector] of [ ["sentimentCycleView", ".sentiment-cycle-toolbar"], ["auctionView", ".auction-page-head-v2"], ["themeLibraryView", ".theme-page-head-v2"], ["popularityView", ".popularity-page-head-v2"], ["dragonView", ".dragon-page-head-v2"], ]) { await page.locator(`[data-view="${view}"]`).first().click(); await page.waitForTimeout(350); pageFrames[view] = await page.evaluate(({ view, headSelector }) => { const workspace = document.getElementById(view); const frame = workspace.getBoundingClientRect(); const head = workspace.querySelector(headSelector).getBoundingClientRect(); const style = getComputedStyle(workspace); return { frame: [Math.round(frame.x), Math.round(frame.y), Math.round(frame.width)], head: [Math.round(head.x), Math.round(head.y), Math.round(head.width)], padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft], background: style.backgroundColor, border: style.borderTopWidth, }; }, { view, headSelector }); } const sentimentFrame = JSON.stringify(pageFrames.sentimentCycleView); for (const view of ["auctionView", "themeLibraryView", "popularityView", "dragonView"]) { expect(JSON.stringify(pageFrames[view])).toBe(sentimentFrame); } }); test("sentiment cycle keeps 600px equal-height layout across zoom viewports", async ({ page }, testInfo) => { const shotDir = testInfo.outputPath("hel-221-shots"); await mockApplication(page, session("user", true)); const viewports = [ { name: "zoom-100", width: 2560, height: 1440 }, { name: "zoom-110", width: 2327, height: 1309 }, { name: "zoom-125", width: 2048, height: 1152 }, ]; for (const viewport of viewports) { await page.setViewportSize({ width: viewport.width, height: viewport.height }); await page.goto("/index.html"); await renderSentimentFixture(page, 20); const layout = await page.evaluate(readSentimentLayout); expect(layout.topDelta, viewport.name).toBeLessThanOrEqual(1); expect(layout.bottomDelta, viewport.name).toBeLessThanOrEqual(1); expect(Math.abs(layout.analysisHeight - 600), viewport.name).toBeLessThanOrEqual(1); expect(layout.chartHeight, viewport.name).toBeGreaterThanOrEqual(450); expect(layout.detailAfterAnalysis, viewport.name).toBe(true); expect(layout.tableVisible, viewport.name).toBe(true); expect(layout.smallVisible, viewport.name).toBe(true); expect(layout.overflowX, viewport.name).toBe(false); await page.screenshot({ path: `${shotDir}/day-${viewport.name}.png`, fullPage: true, }); } await page.setViewportSize({ width: 2560, height: 1440 }); await page.locator("#themeToggle").click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); await page.waitForTimeout(200); const nightLayout = await page.evaluate(readSentimentLayout); expect(nightLayout.topDelta).toBeLessThanOrEqual(1); expect(nightLayout.bottomDelta).toBeLessThanOrEqual(1); expect(Math.abs(nightLayout.analysisHeight - 600)).toBeLessThanOrEqual(1); await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } }); const tooltip = page.locator("#sentimentChartTooltip"); await expect(tooltip).toBeVisible(); await expect(tooltip).toContainText("温度"); const tooltipStyle = await tooltip.evaluate((node) => { const style = getComputedStyle(node); const bold = getComputedStyle(node.querySelector("b") || node); return { background: style.backgroundColor, color: style.color, bold: bold.color }; }); expect(tooltipStyle.background).toBe("rgb(38, 41, 62)"); expect(tooltipStyle.color).toBe("rgb(232, 234, 237)"); expect(tooltipStyle.bold).toBe("rgb(232, 234, 237)"); await page.screenshot({ path: `${shotDir}/night-zoom-100.png`, fullPage: true }); await page.locator("#themeToggle").click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } }); await expect(tooltip).toBeVisible(); const lightTooltip = await tooltip.evaluate((node) => { const style = getComputedStyle(node); return { background: style.backgroundColor, color: style.color }; }); expect(lightTooltip.background).toBe("rgb(31, 35, 41)"); expect(lightTooltip.color).toBe("rgb(255, 255, 255)"); }); test("limit-up pool separates stock identity and restores the reason column", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.evaluate(() => { state.dashboard.limits = [{ code: "603221", name: "爱丽家居", streak: 4, change: 9.98, price: 14, sector: "家居用品", first_time: "09:25:01", last_time: "09:25:01", open_times: 0, turnover_rate: 0.41, amount_billion: 1.2, seal_amount_million: 5200, reason: "家居消费方向走强", }]; renderLimitTable(); }); await page.locator('[data-view="limitPool"]').first().click(); expect((await page.locator("#limitTable thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([ "序号", "股票", "连板", "涨幅(%)", "价格(元)", "所属板块", "首封", "最后封板", "开板(次)", "换手率(%)", "成交额(亿)", "封单额(万)", "涨停原因", ]); const cells = page.locator("#limitTableBody tr").first().locator("td"); await expect(cells).toHaveCount(13); await expect(cells.nth(1)).toContainText("爱丽家居"); await expect(cells.nth(1)).toContainText("603221"); await expect(cells.nth(12)).toHaveText("家居消费方向走强"); for (const [view, table, reasonLabel] of [ ["limitPool", "limitTable", "涨停原因"], ["brokenView", "brokenTable", "炸板原因"], ["downView", "downTable", "风险线索"], ["yesterdayView", "yesterdayTable", "涨停逻辑"], ]) { await page.locator(`[data-view="${view}"]`).first().click(); const geometry = await page.locator(`#${table}`).evaluate((tableNode, reason) => { const headers = [...tableNode.tHead.rows[0].cells]; const widths = headers.map((header) => header.getBoundingClientRect().width); const reasonIndex = headers.findIndex((header) => header.textContent.trim() === reason); const numeric = headers.map((header, index) => ({ header, index })).filter(({ header }) => header.classList.contains("num")); return { index: widths[0], reason: widths[reasonIndex], numericMax: Math.max(...numeric.map(({ index }) => widths[index])), numericAligned: numeric.every(({ header }) => getComputedStyle(header).textAlign === "right"), numericVariant: numeric.every(({ header }) => getComputedStyle(header).fontVariantNumeric.includes("tabular-nums")), }; }, reasonLabel); expect(geometry.reason).toBeGreaterThan(geometry.numericMax); expect(geometry.index).toBeLessThan(geometry.numericMax); expect(geometry.numericAligned).toBe(true); expect(geometry.numericVariant).toBe(true); } }); test("broken pool matches the approved table structure and keeps independent interactions", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.evaluate(() => { state.dashboard.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: "芯片方向冲高回落" }, { code: "300214", name: "日科化学", change: 15.59, price: 11.86, sector: "化学制品", first_time: "09:54:21", open_times: 1, turnover_rate: 19.02, amount_billion: 10.37, reason: "化工板块异动" }, { code: "601678", name: "滨化股份", change: 5.14, price: 6.54, sector: "化学原料", first_time: "09:35:55", open_times: 8, turnover_rate: 25.23, amount_billion: 34.64, reason: "高位反复开板" }, ]; renderBrokenTable(state.dashboard.broken); }); await page.locator('[data-view="brokenView"]').first().click(); await expect(page.locator("#brokenView")).toHaveClass(/redesigned-broken-view/); expect((await page.locator("#brokenTable thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([ "序号", "股票", "现价涨幅(%)", "距涨停(%)", "价格(元)", "所属板块", "首次触板", "开板(次)", "换手率(%)", "成交额(亿)", "炸板原因", ]); await expect(page.locator("#brokenTableBody tr")).toHaveCount(3); await expect(page.locator("#brokenTableBody tr").nth(0)).toContainText("0.23"); await expect(page.locator("#brokenTableBody tr").nth(1)).toContainText("4.41"); await expect(page.locator("#brokenTableBody tr").nth(2)).toContainText("反复炸 ×8"); await expect(page.locator("#brokenTableBody")).toContainText("芯片方向冲高回落"); await page.locator("#brokenSearch").fill("半导体"); await expect(page.locator("#brokenTableBody tr")).toHaveCount(1); await expect(page.locator("#brokenTableBody")).toContainText("通富微电"); await page.locator("#brokenSearch").fill(""); await page.locator('[data-broken-sort="amount_billion"]').click(); await expect(page.locator("#brokenTableBody tr").first()).toContainText("通富微电"); await page.locator('[data-broken-sort="amount_billion"]').click(); await expect(page.locator("#brokenTableBody tr").first()).toContainText("日科化学"); await expect(page.locator("#brokenCount")).toHaveText("3 只"); await expect(page.locator("#brokenMeta")).toContainText("数据日期 2026-07-22"); expect(await page.evaluate(() => [ brokenLimitRate({ code: "600000", name: "普通股票" }), brokenLimitRate({ code: "300001", name: "创业板股票" }), brokenLimitRate({ code: "830001", name: "北交所股票" }), brokenLimitRate({ code: "300001", name: "ST测试" }), ])).toEqual([10, 20, 30, 10]); }); test("down-limit pool matches the approved risk-cluster table structure", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.evaluate(() => { state.dashboard.down_limits = [ { code: "000037", name: "深南电A", change: -10.03, price: 8.97, sector: "电力", turnover_rate: 11.78, amount_billion: 3.68, streak: 2, reason: "连续弱势跌停" }, { code: "000539", name: "粤电力A", change: -10.02, price: 5.66, sector: "电力", turnover_rate: 5.48, amount_billion: 8.16, streak: 1, reason: "板块集中释放风险" }, { code: "001896", name: "豫能控股", change: -10.01, price: 14.39, sector: "电力", turnover_rate: 9.09, amount_billion: 20.64, streak: 1, reason: "高位补跌" }, { code: "300045", name: "华力创通", change: -20.03, price: 11.46, sector: "军工电子", turnover_rate: 9.54, amount_billion: 5.76, streak: 1, reason: "放量破位" }, ]; renderDownTable(state.dashboard.down_limits); }); await page.locator('[data-view="downView"]').first().click(); await expect(page.locator("#downView")).toHaveClass(/redesigned-down-view/); expect((await page.locator("#downTable thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([ "序号", "股票", "跌幅(%)", "价格(元)", "所属板块", "换手率(%)", "成交额(亿)", "连续跌停(天)", "风险线索", ]); await expect(page.locator("#downTableBody tr")).toHaveCount(4); await expect(page.locator("#downSectorCluster")).toHaveText("电力集中跌停 ×3"); await expect(page.locator("#downSectorCluster")).toBeVisible(); await expect(page.locator("#downTableBody tr").first()).toContainText("2"); await expect(page.locator("#downTableBody")).toContainText("连续弱势跌停"); await page.locator("#downSearch").fill("军工电子"); await expect(page.locator("#downTableBody tr")).toHaveCount(1); await expect(page.locator("#downTableBody")).toContainText("华力创通"); await page.locator("#downSearch").fill(""); await page.locator('[data-down-sort="change"]').click(); await expect(page.locator("#downTableBody tr").first()).toContainText("华力创通"); await page.locator('[data-down-sort="amount_billion"]').click(); await expect(page.locator("#downTableBody tr").first()).toContainText("深南电A"); await expect(page.locator("#downCount")).toHaveText("4 只"); await expect(page.locator("#downMeta")).toContainText("数据日期 2026-07-22"); }); test("yesterday-limit pool exposes outcome summaries and combined filters", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.evaluate(() => { state.dashboard.meta.previous_trade_date = "2026-07-21"; state.dashboard.yesterday_limits = [ { code: "000011", name: "深物业A", prior_streak: 1, current_change: 9.99, outcome: "晋级", current_streak: 2, sector: "房地产开发", reason: "地产政策预期" }, { code: "000533", name: "顺钠股份", prior_streak: 1, current_change: 10.02, outcome: "晋级", current_streak: 2, sector: "电网设备", reason: "电网设备走强" }, { code: "000017", name: "深中华A", prior_streak: 1, current_change: 2.11, outcome: "断板", current_streak: 0, sector: "饰品", reason: "消费修复" }, { code: "000035", name: "中国天楹", prior_streak: 1, current_change: -5.38, outcome: "断板", current_streak: 0, sector: "环境治理", reason: "环保题材" }, { code: "001258", name: "立新能源", prior_streak: 6, current_change: 7.60, outcome: "炸板", current_streak: 0, sector: "电力", reason: "新能源核心" }, { code: "001388", name: "信通电子", prior_streak: 1, current_change: -9.99, outcome: "跌停", current_streak: 0, sector: "电网设备", reason: "智能电网" }, ]; renderYesterdayTable(state.dashboard.yesterday_limits); }); await page.locator('[data-view="yesterdayView"]').first().click(); await expect(page.locator("#yesterdayView")).toHaveClass(/redesigned-yesterday-view/); expect((await page.locator("#yesterdayTable thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([ "序号", "股票", "昨日高度(板)", "今日涨幅(%)", "今日结果", "当前高度(板)", "所属板块", "涨停逻辑", ]); await expect(page.locator("#yesterdayAllCount")).toHaveText("6"); await expect(page.locator("#yesterdayAdvanceCount")).toHaveText("2"); await expect(page.locator("#yesterdayAdvanceRate")).toHaveText("晋级率 33.3%"); await expect(page.locator("#yesterdayPositiveCount")).toHaveText("4"); await expect(page.locator("#yesterdayPositiveRate")).toHaveText("兑现率 66.7%"); await expect(page.locator("#yesterdayFailCount")).toHaveText("2"); await expect(page.locator("#yesterdayRiskCount")).toHaveText("2"); await expect(page.locator("#yesterdayRiskRate")).toHaveText("亏钱效应 33.3%"); await page.locator('[data-yesterday-filter="positive"]').click(); await expect(page.locator("#yesterdayTableBody tr")).toHaveCount(4); await expect(page.locator("#yesterdayTableBody")).toContainText("深物业A"); await expect(page.locator("#yesterdayTableBody")).toContainText("立新能源"); await page.locator('[data-yesterday-filter="risk"]').click(); await expect(page.locator("#yesterdayTableBody tr")).toHaveCount(2); await expect(page.locator("#yesterdayTableBody")).toContainText("立新能源"); await expect(page.locator("#yesterdayTableBody")).toContainText("信通电子"); await page.locator("#yesterdaySearch").fill("电网设备"); await expect(page.locator("#yesterdayTableBody tr")).toHaveCount(1); await expect(page.locator("#yesterdayTableBody")).toContainText("信通电子"); await page.locator("#yesterdaySearch").fill(""); await page.locator('[data-yesterday-filter="all"]').click(); await page.locator('[data-yesterday-sort="current_change"]').click(); await expect(page.locator("#yesterdayTableBody tr").first()).toContainText("顺钠股份"); await expect(page.locator("#yesterdayTableBody tr").nth(2).locator("td").nth(5)).toBeEmpty(); await expect(page.locator("#yesterdayTableBody")).toContainText("地产政策预期"); await expect(page.locator("#yesterdayMeta")).toHaveText(" · 昨日 2026-07-21 → 今日 2026-07-22"); }); test("limit-up performance transfers the approved tier cards and market conclusion", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.evaluate(() => { state.dashboard.meta.previous_trade_date = "2026-07-21"; state.dashboard.meta.updated_at = "2026-07-22T15:00:00+08:00"; state.dashboard.overview = { ...state.dashboard.overview, up_count: 555, flat_count: 0, down_count: 4940, limit_up_count: 40, limit_down_count: 25, sentiment_phase: "退潮", }; state.dashboard.limit_performance = [ { level: 6, label: "昨日6板", count: 1, advanced: 0, advance_rate: 0, positive_rate: 0, average_change: -9.8 }, { level: 4, label: "昨日4板", count: 1, advanced: 0, advance_rate: 0, positive_rate: 0, average_change: -6.2 }, { 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 }, { level: 1, label: "昨日首板", count: 101, advanced: 13, advance_rate: 12.9, positive_rate: 35.6, average_change: -1.2 }, ]; renderPerformance(state.dashboard.limit_performance); }); await page.locator('[data-view="performanceView"]').first().click(); await expect(page.locator("#performanceView")).toHaveClass(/redesigned-performance-view/); await expect(page.locator("#performanceDateRange")).toHaveText("昨日 2026-07-21 → 今日 2026-07-22"); await expect(page.locator("#performanceCards .performance-stage-card")).toHaveCount(5); await expect(page.locator("#performanceCards .performance-stage-card").first()).toContainText("昨日5板+ → 今日"); await expect(page.locator("#performanceCards .performance-stage-card").first()).toContainText("失效"); await expect(page.locator("#performanceCards .performance-stage-card").nth(2)).toContainText("50.0%"); await expect(page.locator("#performanceCards .performance-stage-card").nth(2)).toContainText("活跃"); await expect(page.locator("#performanceCards .performance-stage-card").nth(4)).toContainText("危险"); await expect(page.locator("#performanceTableBody")).toHaveCount(0); await expect(page.locator("#breadthUpCount")).toHaveText("555"); await expect(page.locator("#breadthDownCount")).toHaveText("4,940"); await expect(page.locator("#breadthRatio")).toHaveText("10.1%"); await expect(page.locator("#breadthWarning")).toHaveText("△ 宽度极差,涨跌停 40:25"); await expect(page.locator("#performanceConclusion")).toContainText("高位晋级率全线失效"); await expect(page.locator("#performanceConclusion")).toContainText("昨日3板晋级率最高"); await expect(page.locator("#performanceConclusion")).toContainText("当前情绪周期「退潮」"); }); test("market ladder transfers tier bands, sorting and structural insights", async ({ page }) => { await mockApplication(page, session("admin", true)); await page.goto("/index.html"); await page.evaluate(() => { state.dashboard.meta.previous_trade_date = "2026-07-21"; state.dashboard.yesterday_limits = [{ prior_streak: 6 }, { prior_streak: 3 }]; state.dashboard.limit_performance = [ { level: 4, label: "昨日3板", advance_rate: 50 }, { level: 3, label: "昨日2板", advance_rate: 22.2 }, { level: 2, label: "昨日首板", advance_rate: 12.9 }, ]; state.dashboard.ladders = [ { level: 4, label: "4板", count: 2, stocks: [ { code: "002879", name: "长缆科技", sector: "电网设备", first_time: "09:25:00", open_times: 2, seal_amount_million: 7247 }, { code: "603221", name: "爱丽家居", sector: "家居用品", first_time: "09:25:01", open_times: 0, seal_amount_million: 27572 }, ] }, { level: 3, label: "3板", count: 2, stocks: [ { code: "000595", name: "新能股份", sector: "电力", first_time: "09:25:00", open_times: 14, seal_amount_million: 1239 }, { code: "301234", name: "五洲医疗", sector: "医疗器械", first_time: "09:25:00", open_times: 0, seal_amount_million: 39679 }, ] }, { level: 2, label: "2板", count: 9, stocks: Array.from({ length: 9 }, (_, index) => ({ code: `000${String(index + 1).padStart(3, "0")}`, name: `二板股票${index + 1}`, sector: "电网设备", first_time: `09:3${index}:00`, open_times: index, amount_billion: 1.2, })) }, { level: 1, label: "首板", count: 3, stocks: [ { code: "002374", name: "中锐股份", sector: "包装印刷", first_time: "09:31:33", open_times: 0, seal_amount_million: 5210 }, { code: "300414", name: "中光防雷", sector: "通信设备", first_time: "09:34:42", open_times: 1, seal_amount_million: 3365 }, { code: "002012", name: "凯恩股份", sector: "造纸", first_time: "09:35:45", open_times: 0, seal_amount_million: 2874 }, ] }, ]; renderLadderBoard(state.dashboard.ladders); }); await page.locator('[data-view="ladderView"]').first().click(); await expect(page.locator("#ladderView")).toHaveClass(/redesigned-ladder-view/); await expect(page.locator("#ladderDateRange")).toHaveText("数据日期 2026-07-22"); await expect(page.locator("#ladderBoard .market-ladder-tier")).toHaveCount(5); await expect(page.locator("#ladderBoard .market-ladder-tier").first()).toContainText("5板+"); await expect(page.locator("#ladderBoard .market-ladder-tier").first()).toContainText("断层"); await expect(page.locator("#ladderBoard .market-ladder-stock")).toHaveCount(15); await expect(page.locator("#ladderBoard .market-ladder-tag.one-price")).toHaveCount(2); const equalCardWidths = await page.locator("#ladderBoard .market-ladder-stock").evaluateAll((cards) => cards.map((card) => card.getBoundingClientRect().width)); expect(Math.max(...equalCardWidths) - Math.min(...equalCardWidths)).toBeLessThan(1); await expect(page.locator("#ladderInsights .market-ladder-apex-card")).toContainText("4 板"); await expect(page.locator("#ladderInsights")).toContainText("较昨日 6 板 ↓ 空间压缩"); await expect(page.locator("#ladderInsights .market-ladder-rate-list")).toContainText("50.0%"); await page.locator('[data-ladder-sort="open"]').click(); await expect(page.locator('[data-ladder-sort="open"]')).toHaveClass(/active/); await expect(page.locator("#ladderBoard .market-ladder-tier").nth(2).locator(".market-ladder-stock").first()).toContainText("五洲医疗"); await page.locator('[data-ladder-level="2"]').click(); await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-stock")).toHaveCount(9); await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-more")).toContainText("收起"); await page.locator('[data-ladder-level="2"]').click(); await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-stock")).toHaveCount(8); await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-more")).toContainText("展开剩余 1 只"); const ladderOverflow = await page.evaluate(() => { const group = state.dashboard.ladders.find((item) => item.level === 2); group.stocks = Array.from({ length: 48 }, (_, index) => ({ code: `001${String(index).padStart(3, "0")}`, name: `二板扩展${index + 1}`, sector: "电网设备", first_time: "09:30:00", open_times: index % 4, amount_billion: 1.2, })); group.count = group.stocks.length; state.expandedLadderLevels.add(2); renderLadderBoard(state.dashboard.ladders); const main = document.querySelector(".app-main"); return { clientHeight: main.clientHeight, scrollHeight: main.scrollHeight, overflowY: getComputedStyle(main).overflowY, }; }); expect(ladderOverflow.overflowY).toBe("auto"); expect(ladderOverflow.scrollHeight).toBeGreaterThan(ladderOverflow.clientHeight); }); test("sector rotation transfers the nine-day matrix, tracking and sortable detail", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await mockApplication(page, session("admin", true)); await page.goto("/index.html"); await page.evaluate(() => { const dates = ["20260714", "20260715", "20260716", "20260717", "20260720", "20260721", "20260722", "20260723", "20260724"]; state.dashboard.meta.trade_date = "20260724"; state.rotationHistory = { rows: dates.map((tradeDate, dayIndex) => ({ trade_date: tradeDate, sectors: [ { name: "电网设备", rank: 1, count: 5 + dayIndex, strength: 92 }, { name: "计算机设备与自动化设备", rank: 2, count: 3, strength: 76 }, { name: `轮动板块${dayIndex + 1}`, rank: 3, count: 1, strength: 58 }, ], })), }; state.rotationHistoryKey = `${document.querySelector("#tradeDate").value}:9`; state.dashboard.sector_rotation = [ { rank: 1, name: "电网设备", count: 8, previous_count: 3, delta: 5, strength: 96, max_streak: 4, leader: "长缆科技", amount_billion: 19.6 }, { rank: 2, name: "半导体", count: 3, previous_count: 0, delta: 3, strength: 78, max_streak: 2, leader: "测试股份", amount_billion: 30.0 }, { rank: 3, name: "电力", count: 1, previous_count: 8, delta: -7, strength: 67, max_streak: 3, leader: "新能股份", amount_billion: 7.3 }, ]; state.dashboard.sectors = [ { name: "电网设备", change: 4.8 }, { name: "半导体", change: 2.1 }, { name: "电力", change: -1.6 }, ]; renderRotationHistory(); renderRotationMembers(); }); await page.locator('[data-view="rotationView"]').first().click(); await expect(page.locator("#rotationView")).toHaveClass(/redesigned-rotation-view/); await expect(page.locator("#rotationHistoryRange")).toContainText("2026-07-14 → 2026-07-24"); await expect(page.locator("#rotationHistory .rotation-day")).toHaveCount(9); await expect(page.locator("#rotationHistory .rotation-day").first()).toContainText("07-14"); await expect(page.locator("#rotationHistory .rotation-day").last()).toContainText("07-24"); await expect(page.locator("#rotationHistory .rotation-day").last()).toHaveClass(/latest-day/); await expect(page.locator("#rotationView .rotation-legend")).not.toContainText("单元格 ="); const firstDayCells = page.locator("#rotationHistory .rotation-day").first().locator(".rotation-sector-chip"); await expect(firstDayCells.nth(0)).toHaveClass(/heat-strong/); await expect(firstDayCells.nth(1)).toHaveClass(/heat-warm/); await expect(firstDayCells.nth(2)).toHaveClass(/heat-mild/); const cellVisuals = await firstDayCells.evaluateAll((cells) => cells.map((cell) => { const style = getComputedStyle(cell); return { background: style.backgroundColor, radius: parseFloat(style.borderRadius), duration: style.transitionDuration }; })); expect(new Set(cellVisuals.map((item) => item.background)).size).toBe(3); expect(cellVisuals.every((item) => item.radius >= 7)).toBe(true); expect(cellVisuals.every((item) => item.duration.includes("0.28s"))).toBe(true); await firstDayCells.nth(1).hover(); await page.waitForTimeout(300); expect(await firstDayCells.nth(1).evaluate((cell) => getComputedStyle(cell).transform)).not.toBe("none"); const matrixFits = await page.locator("#rotationHistory").evaluate((element) => element.scrollWidth <= element.clientWidth + 1); expect(matrixFits).toBe(true); const longNameWrapsWithoutClipping = await page.locator("#rotationHistory .rotation-sector-chip strong", { hasText: "计算机设备与自动化设备" }).first().evaluate((element) => ({ horizontal: element.scrollWidth <= element.clientWidth + 1, vertical: element.scrollHeight <= element.clientHeight + 1, })); expect(longNameWrapsWithoutClipping).toEqual({ horizontal: true, vertical: true }); await page.locator('[data-rotation-sector="电网设备"]').first().click(); await expect(page.locator("#rotationTracker")).toBeVisible(); await expect(page.locator("#rotationTracker")).toContainText("近 9 日在榜 9 天"); await expect(page.locator("#rotationDetailTitle")).toHaveText("电网设备成分股"); await expect(page.locator("#rotationDetailMeta")).toContainText("2 / 3 只"); await expect(page.locator("#rotationTableBody tr")).toHaveCount(3); await expect(page.locator("#rotationTableBody tr").first()).toContainText("长缆科技"); await expect(page.locator("#rotationTableBody tr").last()).toContainText("当日无行情"); await page.locator("#rotationTracker .rotation-track-cancel").click(); await expect(page.locator("#rotationTracker")).toBeHidden(); await expect(page.locator("#rotationMembersEmpty")).toContainText("点击上方任意板块查看成分股"); await page.locator('[data-rotation-order="latest"]').click(); await expect(page.locator("#rotationHistory .rotation-day").first()).toContainText("07-24"); await expect(page.locator("#rotationHistoryRange")).toContainText("由近到远,左侧为最新交易日"); }); test("collection auction transfers the prototype hierarchy and keeps every dataset workflow", 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="auctionView"]').first().click(); await expect(page.locator("#auctionPhaseTitle")).toHaveText("今日竞价已定格"); await page.evaluate(() => { const base = state.auctionData.focus_rows[0]; state.auctionData.focus_rows = [ { ...base, code: "002141", name: "高分标的", attention_score: 92, change: 6.2, amount_million: 30, volume_ratio: 4.8 }, { ...base, code: "000001", name: "低分标的", attention_score: 68, change: -1.5, amount_million: 8, volume_ratio: 1.2, expectation: "低于预期", core_tags: [] }, ]; state.auctionData.summary = { ...state.auctionData.summary, focus_count: 2, candidate_count: 2, stock_count: 5470, one_price_count: 1, amount_billion: 202.8 }; state.auctionData.themes.carry = [ { name: "新型电力", status: "强承接", prior_limit_count: 5, leader: "立新能源", median_change: 2.36 }, { name: "专用机械", status: "有承接", prior_limit_count: 6, leader: "长城军工", median_change: 1.82 }, { name: "化工原料", status: "有承接", prior_limit_count: 8, leader: "金牛化工", median_change: 1.54 }, { name: "电气设备", status: "承接弱", prior_limit_count: 33, leader: "长缆科技", median_change: 0.88 }, { name: "运输设备", status: "分歧", prior_limit_count: 5, leader: "北自科技", median_change: 0 }, ]; state.auctionData.amount_history = Array.from({ length: 10 }, (_, index) => ({ trade_date: `2026-07-${String(index + 13).padStart(2, "0")}`, amount_billion: 150 + index * 6, stock_count: 5000, })); state.auctionSortKey = "attention_score"; state.auctionSortDirection = "desc"; renderAuctionCenter(); }); await expect(page.locator("#auctionView")).toHaveClass(/redesigned-auction-view/); await expect(page.locator("#auctionSummary > div")).toHaveCount(4); await expect(page.locator("#auctionSummary")).toContainText("5,470 只"); const auctionTabGeometry = await page.locator(".auction-tabs-v2").evaluate((tabs) => { const row = tabs.getBoundingClientRect(); const summary = tabs.querySelector("#auctionSummary").getBoundingClientRect(); return { rowRight: row.right, summaryRight: summary.right }; }); expect(Math.abs(auctionTabGeometry.rowRight - auctionTabGeometry.summaryRight)).toBeLessThanOrEqual(1); const datasetLabels = await page.locator("[data-auction-dataset]").allTextContents(); expect(datasetLabels.map((label) => label.replace(/\s+/g, " ").trim())).toEqual(["重点异动 2", "我的自选 1", "全部候选 2", "竞价一字 1"]); const workspaceColumns = await page.locator(".auction-workspace-v2").evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").filter(Boolean).length); expect(workspaceColumns).toBe(2); await expect(page.locator(".auction-side-v2 .auction-side-card")).toHaveCount(2); await expect(page.locator(".auction-news-entry")).toHaveCount(0); await expect(page.locator("#auctionThemeCarry .auction-theme-row")).toHaveCount(5); await expect(page.locator("#auctionAmountTrend .auction-amount-day")).toHaveCount(10); await expect(page.locator("#auctionTableHead th")).toHaveCount(8); await expect(page.locator("#auctionTableHead th[data-auction-sort]")).toHaveCount(4); await expect(page.locator("#auctionTableBody tr").first()).toContainText("高分标的"); await page.locator('#auctionTableHead th[data-auction-sort="attention_score"]').click(); await expect(page.locator("#auctionTableBody tr").first()).toContainText("低分标的"); await page.locator('[data-auction-dataset="onePrice"]').click(); await expect(page.locator("#auctionTableHead th")).toHaveCount(6); await expect(page.locator("#auctionExpectationControls")).toBeHidden(); await expect(page.locator("#auctionSearch")).toBeVisible(); await expect(page.locator("#auctionExportButton")).toBeVisible(); const download = page.waitForEvent("download"); await page.locator("#auctionExportButton").click(); await download; await page.locator("#auctionTableBody tr").first().click(); await expect(page.locator("#stockDialog")).toBeVisible(); await page.locator("#closeStockDialog").click(); await page.setViewportSize({ width: 390, height: 844 }); await expect(page.locator(".auction-summary-v2 > div")).toHaveCount(4); await expect(page.locator(".auction-side-v2 .auction-side-card")).toHaveCount(2); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true); }); test("auction, themes and popularity reuse stock detail interactions", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.locator('[data-view="auctionView"]').first().click(); await expect(page.locator("#auctionPhaseTitle")).toHaveText("今日竞价已定格"); await expect(page.locator("#auctionRefreshButton")).toBeHidden(); await expect(page.locator("#auctionTableBody tr")).toHaveCount(1); await expect(page.locator("#auctionTableBody")).toContainText("人气前5"); await page.locator('[data-auction-filter="above"]').click(); await expect(page.locator("#auctionTableBody tr")).toHaveCount(1); await expect(page.locator('[data-auction-filter="above"]')).toHaveClass(/active/); await expect(page.locator("#auctionThemeCarry")).toContainText("强承接"); await expect(page.locator("#auctionAmountValue")).toHaveText("2.50 亿"); await expect(page.locator("#auctionNewsTitle")).toHaveCount(0); await expect(page.locator(".auction-news-entry")).toHaveCount(0); await page.locator('[data-auction-dataset="onePrice"]').click(); await expect(page.locator("#auctionTableBody tr")).toHaveCount(1); await expect(page.locator("#auctionTableBody")).toContainText("三板以上"); await expect(page.locator("#auctionExpectationControls")).toBeHidden(); await expect(page.locator("#auctionSearch")).toBeVisible(); await page.locator('[data-auction-dataset="watchlist"]').click(); await expect(page.locator("#auctionTableBody tr")).toHaveCount(1); await expect(page.locator("#auctionTableBody")).toContainText("Watch Stock"); await page.locator('[data-auction-dataset="focus"]').click(); await page.evaluate(() => { state.auctionData.meta = { phase: "selection", available: false, actionable: true }; state.auctionData.rows = []; state.auctionData.focus_rows = []; renderAuctionCenter(); }); await expect(page.locator("#auctionPhaseTitle")).toHaveText("等待最终竞价"); await expect(page.locator("#auctionRefreshButton")).toBeVisible(); await expect(page.locator("#auctionEmpty")).toContainText("正在等待 9:25 最终竞价数据"); await page.locator('[data-view="themeLibraryView"]').first().click(); await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(1); await expect(page.locator("#themeMemberTableBody tr")).toHaveCount(1); await expect(page.locator("#themeDetailName")).toHaveText("人工智能"); await page.locator('[data-view="popularityView"]').first().click(); await expect(page.locator("#popularityTableBody tr")).toHaveCount(1); await expect(page.locator("#popularitySummary")).toContainText("双榜共识"); await expect(page.locator("#popularityTableBody")).not.toContainText("双榜共识"); await page.locator("#popularityTableBody tr").click(); await expect(page.locator("#stockDialog")).toBeVisible(); }); test("theme library preserves the full master-detail workflow in its redesigned layout", 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="themeLibraryView"]').first().click(); await expect(page.locator("#themeLibraryView")).toHaveClass(/redesigned-theme-view/); await expect(page.locator(".theme-summary-v2 > div")).toHaveCount(4); await expect(page.locator(".theme-directory-card-v2")).toBeVisible(); await expect(page.locator(".theme-market-card-v2")).toBeVisible(); await expect(page.locator(".theme-members-card-v2")).toBeVisible(); await expect(page.locator("#themeDetailName")).toHaveText("人工智能"); await expect(page.locator("#themeDetailMetrics > div")).toHaveCount(5); await expect(page.locator("#themeMemberCount")).toHaveText("有行情 1 / 1"); await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveAttribute("aria-pressed", "true"); await expect(page.locator("#themeDetailChart")).toHaveCount(0); const themePreviewRequest = page.waitForRequest((request) => request.url().includes("/api/search/detail?") && request.url().includes("type=theme")); await page.locator(".market-preview-trigger").hover(); await themePreviewRequest; await expect(page.locator("#stockPreview")).toBeVisible(); await expect(page.locator("#stockPreviewName")).toHaveText("人工智能"); await expect(page.locator("#stockPreviewSource")).toHaveText("日 K 行情 · 2 个交易日"); const intradayRequest = page.waitForRequest((request) => request.url().includes("/api/chart/intraday?") && request.url().includes("type=theme")); await page.locator('[data-preview-chart="intraday"]').click(); const requestedIntraday = new URL((await intradayRequest).url()); expect(requestedIntraday.searchParams.get("id")).toBe("885728.TI"); await expect(page.locator("#stockPreviewSource")).toHaveText("最新分时 · 1分钟"); await page.locator("#closeStockPreview").click(); await page.locator("#themeSearch").fill("不存在的题材"); await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(0); await expect(page.locator("#themeDirectory")).toContainText("没有匹配的题材"); await page.locator("#themeSearch").fill("人工智能"); await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(1); await page.locator("#themeMemberTableBody tr").click(); await expect(page.locator("#stockDialog")).toBeVisible(); await page.locator("#closeStockDialog").click(); await page.evaluate(() => { const directory = document.querySelector("#themeDirectory"); const directorySeed = directory.querySelector("button"); const members = document.querySelector("#themeMemberTableBody"); const memberSeed = members.querySelector("tr"); for (let index = 0; index < 24; index += 1) directory.appendChild(directorySeed.cloneNode(true)); for (let index = 0; index < 20; index += 1) members.appendChild(memberSeed.cloneNode(true)); }); const desktop = await page.evaluate(() => ({ columns: getComputedStyle(document.querySelector(".theme-library-workspace-v2")).gridTemplateColumns.split(" ").filter(Boolean).length, mainOverflows: document.querySelector(".app-main").scrollHeight > document.querySelector(".app-main").clientHeight + 1, directoryOverflows: document.querySelector("#themeDirectory").scrollHeight > document.querySelector("#themeDirectory").clientHeight + 1, membersOverflow: document.querySelector(".theme-members-frame-v2").scrollHeight > document.querySelector(".theme-members-frame-v2").clientHeight + 1, })); expect(desktop).toEqual({ columns: 2, mainOverflows: false, directoryOverflows: true, membersOverflow: true }); await page.setViewportSize({ width: 390, height: 844 }); await expect(page.locator(".theme-head-actions-v2")).toBeVisible(); const mobile = await page.evaluate(() => ({ columns: getComputedStyle(document.querySelector(".theme-library-workspace-v2")).gridTemplateColumns.split(" ").filter(Boolean).length, pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1, memberTableScrolls: document.querySelector(".theme-members-frame-v2").scrollWidth > document.querySelector(".theme-members-frame-v2").clientWidth + 1, })); expect(mobile).toEqual({ columns: 1, pageFits: true, memberTableScrolls: true }); }); test("popularity ranking transfers the three-glance hierarchy and dynamic source tables", 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="popularityView"]').first().click(); await expect(page.locator("#popularityView")).toHaveClass(/redesigned-popularity-view/); await expect(page.locator("#popularitySummary article")).toHaveCount(3); await expect(page.locator("#popularitySummary article").nth(0)).toContainText("同花顺热度 Top3"); await expect(page.locator("#popularitySummary article").nth(1)).toContainText("东方财富热度 Top3"); await expect(page.locator("#popularitySummary article").nth(2)).toContainText("双榜共识"); const glanceStyles = await page.locator("#popularitySummary").evaluate((summary) => ({ background: getComputedStyle(summary).backgroundColor, shadow: getComputedStyle(summary).boxShadow, borderWidth: getComputedStyle(summary).borderWidth, cardBackgrounds: [...summary.children].map((card) => getComputedStyle(card).backgroundColor), cardShadows: [...summary.children].map((card) => getComputedStyle(card).boxShadow), })); expect(new Set(glanceStyles.cardBackgrounds).size).toBe(1); expect(glanceStyles.cardBackgrounds[0]).not.toBe(glanceStyles.background); expect(glanceStyles.shadow).toBe("none"); expect(glanceStyles.borderWidth).toBe("0px"); expect(glanceStyles.cardShadows).toEqual(["none", "none", "none"]); await expect(page.locator("#popularityTableTitle")).toHaveText("双榜综合榜"); await expect(page.locator("#popularityTableHead th")).toHaveCount(8); await expect(page.locator("#popularityTableHead")).not.toContainText("榜单状态"); await expect(page.locator("#popularityTableBody .popularity-source-tag-v2")).toHaveCount(0); await page.locator('[data-popularity-source="ths"]').click(); await expect(page.locator("#popularityTableTitle")).toHaveText("同花顺榜"); await expect(page.locator("#popularityTableHead")).toContainText("榜单状态"); await expect(page.locator("#popularityTableHead")).not.toContainText("东方财富"); await expect(page.locator("#popularityTableBody .popularity-source-tag-v2")).toHaveText("双榜共识"); await page.locator('[data-popularity-source="dc"]').click(); await expect(page.locator("#popularityTableTitle")).toHaveText("东方财富榜"); await expect(page.locator("#popularityTableHead")).not.toContainText("同花顺"); await page.locator("#popularitySearch").fill("不存在的股票"); await expect(page.locator("#popularityEmpty")).toBeVisible(); await page.locator("#popularitySearch").fill("Test Stock"); await expect(page.locator("#popularityTableBody tr")).toHaveCount(1); await page.locator("#popularityTableBody tr").click(); await expect(page.locator("#stockDialog")).toBeVisible(); await page.locator("#closeStockDialog").click(); await page.locator('[data-popularity-source="combined"]').click(); await page.evaluate(() => { const body = document.querySelector("#popularityTableBody"); const seed = body.querySelector("tr"); for (let index = 0; index < 30; index += 1) body.appendChild(seed.cloneNode(true)); }); const desktop = await page.evaluate(() => ({ mainOverflows: document.querySelector(".app-main").scrollHeight > document.querySelector(".app-main").clientHeight + 1, tableOverflows: document.querySelector(".popularity-table-frame-v2").scrollHeight > document.querySelector(".popularity-table-frame-v2").clientHeight + 1, })); expect(desktop).toEqual({ mainOverflows: false, tableOverflows: true }); await page.setViewportSize({ width: 390, height: 844 }); const mobile = await page.evaluate(() => ({ pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1, tableScrolls: document.querySelector(".popularity-table-frame-v2").scrollWidth > document.querySelector(".popularity-table-frame-v2").clientWidth + 1, })); expect(mobile).toEqual({ pageFits: true, tableScrolls: true }); }); test("dragon-tiger redesign keeps the merged empty state and independent card hit zones", 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="dragonView"]').first().click(); await expect(page.locator("#dragonView")).toHaveClass(/redesigned-dragon-view/); await expect(page.locator("#dragonEmptyState")).toBeVisible(); await expect(page.locator("#dragonEmptyTitle")).toContainText("2026-07-22"); await expect(page.locator("#dragonDailyContent")).toBeHidden(); await expect(page.locator("#dragonPreviousButton")).toBeVisible(); await expect(page.locator("#dragonEmptyRefreshButton")).toBeVisible(); await page.evaluate(() => { const operation = (code, name, net) => ({ code, name, change: net > 0 ? 6.8 : -2.4, buy_million: net > 0 ? 42 : 5, sell_million: net > 0 ? 7 : 31, net_buy_million: net, direction: net > 0 ? "买入" : "卖出", seat_name: "测试营业部", tag: "题材核心", reason: "日涨幅偏离值达标", }); const trader = (id, name, net, code) => ({ id, name, identity_type: "trader", recognized: true, description: "聚焦市场核心,顺势参与强势方向", stock_count: 1, operation_count: 1, buy_million: net > 0 ? 42 : 5, sell_million: net > 0 ? 7 : 31, net_buy_million: net, operations: [operation(code, `${name}标的`, net)], }); state.dragonTiger = { meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "success" }, summary: { trader_count: 3, operation_count: 3, seat_net_buy_million: 34, active_stock_count: 3 }, traders: [trader("alpha", "甲游资", 35, "002141"), trader("beta", "乙游资", -26, "000001"), trader("gamma", "丙游资", 25, "000002")], unclassified_seats: [], }; renderDragonTiger(); }); await expect(page.locator("#dragonEmptyState")).toBeHidden(); await expect(page.locator("#dragonDailyContent")).toBeVisible(); await expect(page.locator("#dragonSummary .dragon-metric")).toHaveCount(4); await expect(page.locator("#dragonTraderList .dragon-trader-card")).toHaveCount(3); await expect(page.locator("#dragonTraderList .dragon-card-hit-zone")).toHaveCount(3); await expect(page.locator("#dragonTraderDetail")).toBeVisible(); await expect(page.locator("#dragonTraderDetail")).toContainText("甲游资"); expect((await page.locator("#dragonTraderDetail .dragon-operation-table thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([ "序号", "股票", "方向", "涨幅(%)", "买入(百万)", "卖出(百万)", "净额(百万)", "关联席位", "标签 / 上榜原因", ]); const operationCells = page.locator("#dragonTraderDetail .dragon-operation-table tbody tr").first().locator("td"); await expect(operationCells.nth(0)).toHaveText("1"); await expect(operationCells.nth(1)).toContainText("002141"); await expect(operationCells.nth(1)).toContainText("甲游资标的"); const hitZones = await page.locator("#dragonTraderList .dragon-card-hit-zone").evaluateAll((zones) => zones.map((zone) => { const box = zone.getBoundingClientRect(); return { left: box.left, right: box.right, width: box.width }; })); expect(hitZones.every((zone) => zone.width >= 18)).toBe(true); expect(hitZones.slice(1).every((zone, index) => zone.left >= hitZones[index].right - 1)).toBe(true); await page.locator('[data-dragon-trader="gamma"]').hover(); await expect(page.locator('[data-dragon-card="gamma"]')).toHaveClass(/hovered/); await page.locator('[data-dragon-trader="beta"]').click(); await expect(page.locator('[data-dragon-trader="beta"]')).toHaveAttribute("aria-pressed", "true"); await expect(page.locator("#dragonTraderDetail")).toContainText("乙游资"); await page.locator('[data-dragon-filter="buy"]').click(); await expect(page.locator("#dragonTraderList .dragon-trader-card")).toHaveCount(2); await page.locator('[data-dragon-filter="sell"]').click(); await expect(page.locator("#dragonTraderList .dragon-trader-card")).toHaveCount(1); await page.locator("#dragonSearch").fill("不存在"); await expect(page.locator("#dragonTraderList .dragon-empty")).toBeVisible(); await expect(page.locator("#dragonTraderDetail")).toBeHidden(); await page.locator("#dragonSearch").fill(""); await page.locator('[data-dragon-filter="all"]').click(); await page.locator("#dragonTraderDetail tbody tr").first().click(); await expect(page.locator("#stockDialog")).toBeVisible(); await page.locator("#closeStockDialog").click(); await page.setViewportSize({ width: 1366, height: 768 }); const scrollOwnership = await page.evaluate(() => { const body = document.querySelector("#dragonTraderDetail tbody"); const seed = body.querySelector("tr"); for (let index = 0; index < 20; index += 1) body.appendChild(seed.cloneNode(true)); const main = document.querySelector(".app-main"); const daily = document.querySelector("#dragonDailyContent"); const operations = document.querySelector("#dragonTraderDetail .trader-operations"); return { mainOverflow: getComputedStyle(main).overflowY, pageScrolls: main.scrollHeight > main.clientHeight, dailyOverflow: getComputedStyle(daily).overflowY, dailyFits: daily.scrollHeight <= daily.clientHeight + 1, operationOverflow: getComputedStyle(operations).overflowY, operationsFit: operations.scrollHeight <= operations.clientHeight + 1, descriptionSize: parseFloat(getComputedStyle(document.querySelector("#dragonTraderDetail .dragon-detail-header p")).fontSize), }; }); expect(scrollOwnership).toEqual({ mainOverflow: "auto", pageScrolls: true, dailyOverflow: "visible", dailyFits: true, operationOverflow: "auto", operationsFit: true, descriptionSize: 13, }); await page.locator("#dragonProfilesButton").click(); await expect(page.locator("#dragonProfilesContent")).toBeVisible(); await expect(page.locator("#dragonDailyContent")).toBeHidden(); await expect(page.locator("#hotMoneyProfileSummary > span")).toHaveCount(3); await expect(page.locator("#hotMoneyProfileList .hot-money-profile-row-v2")).toHaveCount(3); await expect(page.locator("#hotMoneyProfileDetail")).toContainText("赵老哥"); await expect(page.locator("#hotMoneyProfileDetail")).toContainText("华泰证券浙江分公司"); await page.locator("#hotMoneyProfileSearch").fill("宛平南路"); await expect(page.locator("#hotMoneyProfileList .hot-money-profile-row-v2")).toHaveCount(1); await expect(page.locator("#hotMoneyProfileDetail")).toContainText("炒股养家"); await page.locator("#hotMoneyProfileSearch").fill(""); await page.locator('[data-hot-money-profile="hot-money-profile-3"]').click(); await expect(page.locator("#hotMoneyProfileDetail")).toContainText("名录暂未收录该游资的公开简介"); await page.setViewportSize({ width: 390, height: 844 }); const profileMobile = await page.evaluate(() => { const list = document.querySelector("#hotMoneyProfileList").getBoundingClientRect(); const detail = document.querySelector("#hotMoneyProfileDetail").getBoundingClientRect(); return { pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1, detailBelowList: detail.top >= list.bottom - 1, }; }); expect(profileMobile).toEqual({ pageFits: true, detailBelowList: true }); await page.locator("#dragonDailyButton").click(); const mobile = await page.evaluate(() => ({ pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1, operationsScroll: document.querySelector("#dragonTraderDetail .trader-operations").scrollWidth > document.querySelector("#dragonTraderDetail .trader-operations").clientWidth + 1, })); expect(mobile).toEqual({ pageFits: true, operationsScroll: true }); }); test("auction owns a single vertical scroll container across desktop densities", async ({ page }) => { await page.setViewportSize({ width: 1920, height: 1080 }); await mockApplication(page, session("admin", true)); await page.goto("/index.html"); await page.locator('[data-view="auctionView"]').first().click(); await page.evaluate(() => { const body = document.querySelector("#auctionTableBody"); const seed = body.querySelector("tr"); if (!seed) return; for (let index = 0; index < 28; index += 1) body.appendChild(seed.cloneNode(true)); }); const measure = () => page.evaluate(() => { const box = (selector) => { const element = document.querySelector(selector); const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return { clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, overflowY: style.overflowY, top: rect.top, bottom: rect.bottom, width: rect.width, }; }; return { viewportHeight: innerHeight, html: box("html"), body: box("body"), main: box(".app-main"), view: box("#auctionView"), primary: box(".auction-primary-card"), frame: box(".auction-table-frame-v2"), side: box(".auction-side-v2"), }; }); for (const viewport of [{ width: 1920, height: 1080 }, { width: 1536, height: 864 }]) { await page.setViewportSize(viewport); const layout = await measure(); expect(layout.html.scrollHeight).toBeLessThanOrEqual(layout.viewportHeight); expect(layout.body.scrollHeight).toBeLessThanOrEqual(layout.viewportHeight); expect(layout.main.overflowY).toBe("hidden"); expect(layout.frame.overflowY).toBe("auto"); expect(layout.frame.scrollHeight).toBeGreaterThan(layout.frame.clientHeight); expect(layout.side.top).toBeLessThan(layout.primary.bottom); } await page.setViewportSize({ width: 1280, height: 720 }); const compact = await measure(); expect(compact.html.scrollHeight).toBeLessThanOrEqual(compact.viewportHeight); expect(compact.main.overflowY).toBe("hidden"); expect(compact.frame.overflowY).toBe("auto"); expect(compact.frame.scrollHeight).toBeGreaterThan(compact.frame.clientHeight); expect(compact.side.top).toBeLessThan(compact.primary.bottom); await page.setViewportSize({ width: 3840, height: 2160 }); const wide = await measure(); expect(wide.html.scrollHeight).toBeLessThanOrEqual(wide.viewportHeight); expect(wide.view.width).toBeLessThanOrEqual(wide.main.clientWidth + 1); expect(wide.view.scrollWidth).toBeLessThanOrEqual(wide.view.clientWidth + 1); }); 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('[data-screener-mode="quant"]')).toBeDisabled(); await expect(page.locator("#quantRunButton")).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"); await expect(page.locator("#stockPreviewSource")).toHaveText("日 K 行情 · 2 个交易日"); await expect(page.locator('[data-preview-chart="daily"]')).toHaveClass(/active/); await page.locator('[data-preview-chart="intraday"]').click(); await expect(page.locator("#stockPreviewDate")).toHaveText("2026-07-24"); await expect(page.locator("#stockPreviewSource")).toHaveText("最新分时 · 1分钟"); const canvasColors = await page.locator("#stockPreviewChart").evaluate((canvas) => { const pixels = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height).data; const colors = new Set(); for (let index = 0; index < pixels.length; index += 16) { if (pixels[index + 3]) colors.add(`${pixels[index]},${pixels[index + 1]},${pixels[index + 2]}`); } return colors.size; }); expect(canvasColors).toBeGreaterThan(4); }); test("stock hover preview loading state follows the dark chart theme", async ({ page }) => { await mockApplication(page, session("user", true), { previewDelay: 500 }); await page.goto("/index.html"); await page.evaluate(() => { document.documentElement.dataset.theme = "dark"; showStockPreview("002141", document.querySelector("#globalSearchButton")); }); const loading = page.locator("#stockPreviewLoading"); await expect(loading).toBeVisible(); await expect(page.locator('[data-preview-chart="daily"]')).toHaveClass(/active/); const colors = await page.evaluate(() => ({ overlay: getComputedStyle(document.querySelector("#stockPreviewLoading")).backgroundColor, chart: getComputedStyle(document.documentElement).getPropertyValue("--chart-background").trim(), pixel: Array.from( document.querySelector("#stockPreviewChart").getContext("2d").getImageData(10, 10, 1, 1).data, ), })); expect(colors.overlay).not.toBe("rgb(255, 255, 255)"); expect(colors.chart).toBe("#181b1e"); expect(colors.pixel.slice(0, 3)).toEqual([24, 27, 30]); }); test("rising candle body stays hollow and its wick stops at both edges", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); const pixels = await page.evaluate(() => { const canvas = document.createElement("canvas"); canvas.width = 40; canvas.height = 80; const context = canvas.getContext("2d"); context.fillStyle = currentChartPalette().background; context.fillRect(0, 0, 40, 80); const priceY = (value) => 90 - value * 8; drawCandlestick(context, 20, { high: 10, close: 8, open: 6, low: 4 }, priceY, 10); const read = (x, y) => Array.from(context.getImageData(x, y, 1, 1).data); const reddest = (left, top, width, height) => { const data = context.getImageData(left, top, width, height).data; let selected = [0, 0, 0, 0]; for (let index = 0; index < data.length; index += 4) { const pixel = [data[index], data[index + 1], data[index + 2], data[index + 3]]; if (pixel[0] - pixel[1] > selected[0] - selected[1]) selected = pixel; } return selected; }; return { upperWick: reddest(19, 10, 3, 16), bodyCenter: read(20, 34), lowerWick: reddest(19, 43, 3, 17), bodyBorder: reddest(14, 26, 3, 17), }; }); for (const redPixel of [pixels.upperWick, pixels.lowerWick, pixels.bodyBorder]) { expect(redPixel[0] - redPixel[1]).toBeGreaterThan(40); expect(redPixel[0] - redPixel[2]).toBeGreaterThan(40); } expect(pixels.bodyCenter.slice(0, 3)).toEqual([251, 252, 253]); }); test("stock and market detail dialogs switch from daily K to intraday", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.evaluate(() => openStock("002141", { code: "002141", name: "Test Stock", sector: "Test Sector" })); await page.locator('[data-stock-detail-chart="intraday"]').click(); await expect(page.locator("#chartSource")).toHaveText("分时 · 2026-07-24"); await expect(page.locator('[data-stock-detail-chart="intraday"]')).toHaveAttribute("aria-pressed", "true"); await page.locator('[data-stock-detail-chart="daily"]').click(); await expect(page.locator("#chartSource")).toContainText("日 K 行情"); await page.locator("#closeStockDialog").click(); await page.evaluate(() => openEntityDetail({ id: "000001.SH", code: "000001.SH", name: "上证指数", type: "index", type_label: "指数" })); await page.locator('[data-entity-detail-chart="intraday"]').click(); await expect(page.locator("#entityDetailDate")).toHaveText("分时 · 2026-07-24"); await expect(page.locator('[data-entity-detail-chart="intraday"]')).toHaveAttribute("aria-pressed", "true"); const colors = await page.locator("#entityDetailChart").evaluate((canvas) => { const pixels = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height).data; return new Set(Array.from({ length: Math.floor(pixels.length / 16) }, (_, index) => { const offset = index * 16; return `${pixels[offset]},${pixels[offset + 1]},${pixels[offset + 2]},${pixels[offset + 3]}`; })).size; }); expect(colors).toBeGreaterThan(4); const offsets = await page.evaluate(() => [ intradayMinuteOffset("09:30"), intradayMinuteOffset("11:30"), intradayMinuteOffset("13:00"), intradayMinuteOffset("15:00"), ]); expect(offsets).toEqual([0, 120, 120, 240]); }); 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 expect(canvas).toHaveAttribute("data-looping", "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); await page.evaluate(() => { heavenReadingAnimation.startedAt = performance.now() - 13_100; }); await expect(canvas).toHaveAttribute("data-cycle", "1"); await expect(canvas).toHaveAttribute("data-running", "true"); if (mode === "trend") { const completionMs = await page.evaluate(async () => { const started = performance.now(); await heavenReadingAnimation.complete(); return performance.now() - started; }); expect(completionMs).toBeGreaterThanOrEqual(1600); expect(completionMs).toBeLessThan(2000); await expect(canvas).toHaveAttribute("data-running", "false"); } await page.locator("#closeHeavenReadingDialog").click(); await expect(canvas).toHaveAttribute("data-running", "false"); await expect(canvas).toHaveAttribute("data-looping", "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.locator('[data-heart-question-preset="unthemed"]').click(); await page.evaluate(() => startHeartBreathing()); const timing = await page.evaluate(() => ({ remaining: state.heartBreathingEndsAt - Date.now(), incenseNames: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationName, incenseDurations: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration, incenseDelays: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDelay, rippleAnimation: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationName, })); expect(timing.remaining).toBeGreaterThan(45_000); expect(timing.remaining).toBeLessThanOrEqual(46_000); expect(timing.incenseNames).toContain("heart-incense-burn"); expect(timing.incenseNames).toContain("heart-incense-glow"); expect(timing.incenseDurations).toContain("45s"); expect(timing.incenseDelays).toContain("1s"); expect(timing.rippleAnimation).toBe("none"); 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 expect(page.locator(".heart-breath-ripple span").first()).toHaveCSS("transition-duration", "3s"); await expect(page.locator(".heart-breath-ripple span").first()).toHaveCSS("transform", /matrix\(1, 0, 0, 1,/); 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("呼"); await expect(page.locator(".heart-breath-ripple span").first()).toHaveCSS("transition-duration", "4s"); }); test("heart question presets stay editable and lock only during the ritual", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); 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(); const input = page.locator("#heartQuestionInput"); const start = page.locator("#startBreathingButton"); const trade = page.locator('[data-heart-question-preset="trade"]'); const mind = page.locator('[data-heart-question-preset="mind"]'); const unthemed = page.locator('[data-heart-question-preset="unthemed"]'); await expect(input).toHaveValue(""); await expect(start).toBeDisabled(); await expect(trade).toHaveAttribute("aria-pressed", "false"); await expect(mind).toHaveAttribute("aria-pressed", "false"); await expect(unthemed).toHaveAttribute("aria-pressed", "false"); await trade.click(); await expect(input).toHaveValue("关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?"); await expect(trade).toHaveAttribute("aria-pressed", "true"); await mind.click(); await expect(input).toHaveValue("此刻影响我交易判断的情绪、执念或盲点是什么?"); await expect(mind).toHaveAttribute("aria-pressed", "true"); await unthemed.click(); await expect(input).toHaveValue("不设具体问题,只观此刻一念。"); await expect(unthemed).toHaveAttribute("aria-pressed", "true"); await input.fill("我是否因为害怕错过而忽略了这笔交易的退出条件?"); await expect(start).toBeEnabled(); await expect(trade).toHaveAttribute("aria-pressed", "false"); await expect(mind).toHaveAttribute("aria-pressed", "false"); await expect(unthemed).toHaveAttribute("aria-pressed", "false"); expect(await page.evaluate(() => ({ question: state.heartQuestion, preset: state.heartQuestionPreset, }))).toEqual({ question: "我是否因为害怕错过而忽略了这笔交易的退出条件?", preset: "custom", }); await start.click(); await expect(page.locator("#heartBreathing")).toHaveClass(/active-heart-stage/); await expect(input).toBeDisabled(); await expect(trade).toBeDisabled(); await page.locator("#heartBreathing [data-heart-return]").click(); await expect(page.locator("#heartIntro")).toHaveClass(/active-heart-stage/); await expect(input).toBeEnabled(); await expect(input).toHaveValue("我是否因为害怕错过而忽略了这笔交易的退出条件?"); await expect(trade).toBeEnabled(); }); test("stylesheet layers do not repeat identical rules in the same cascade context", async ({ page }) => { await mockApplication(page, session("admin", true)); await page.goto("/index.html"); const duplicates = await page.evaluate(() => { const occurrences = new Map(); const visitRules = (rules, href, context = []) => { for (const rule of Array.from(rules || [])) { if (typeof rule.selectorText === "string" && rule.style) { const key = `${context.join("\u0001")}\u0000${rule.selectorText}\u0000${rule.style.cssText}`; const rows = occurrences.get(key) || []; rows.push(href); occurrences.set(key, rows); continue; } if (!rule.cssRules) continue; const condition = rule.conditionText || rule.media?.mediaText || rule.name || ""; visitRules(rule.cssRules, href, [...context, `${rule.type}:${condition}`]); } }; for (const sheet of Array.from(document.styleSheets)) { const href = sheet.href ? new URL(sheet.href).pathname : "inline"; visitRules(sheet.cssRules, href); } return Array.from(occurrences.entries()) .filter(([, paths]) => paths.length > 1) .map(([rule, paths]) => ({ rule, paths })); }); expect(duplicates).toEqual([]); }); 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?ui=desktop"); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); expect(overflow).toBeLessThanOrEqual(1); await expect(page.locator("#globalSearchButton")).toBeHidden(); await expect(page.locator("#headerMenuButton")).toBeVisible(); await page.locator("#headerMenuButton").click(); await expect(page.locator("#headerCommandGroup")).toBeVisible(); await expect(page.locator('[data-mobile-command-target="globalSearchButton"]')).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.locator("#headerCommandGroup")).toBeHidden(); await expect(page.locator(".module-nav .mobile-primary-tab:visible")).toHaveCount(5); await expect(page.locator('[data-view="limitPool"]')).toHaveClass(/mobile-active/); for (const entry of await page.locator(".module-nav .mobile-primary-tab:visible").all()) { const box = await entry.boundingBox(); expect(box.height).toBeGreaterThanOrEqual(44); } const mobileShell = await page.evaluate(() => { const header = document.querySelector(".app-header").getBoundingClientRect(); const main = document.querySelector(".app-main").getBoundingClientRect(); return { headerBottom: header.bottom, mainTop: main.top }; }); expect(mobileShell.mainTop).toBeGreaterThanOrEqual(mobileShell.headerBottom - 1); }); 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?ui=desktop"); const registeredViews = await page.evaluate(() => window.XiaobaiPages.pages.map((entry) => entry.id)); for (const viewport of [ { width: 430, height: 932 }, { width: 390, height: 844 }, { width: 320, height: 720 }, ]) { await page.setViewportSize(viewport); await expect(page.locator("body")).toHaveClass(/mobile-shell/); for (const theme of ["light", "dark"]) { for (const view of registeredViews) { await page.evaluate(({ targetView, targetTheme }) => { document.documentElement.dataset.theme = targetTheme; openView(targetView); }, { targetView: view, targetTheme: theme }); const geometry = await page.evaluate(() => ({ overflow: document.documentElement.scrollWidth - innerWidth, visibleEntries: Array.from(document.querySelectorAll(".module-nav .mobile-primary-tab")) .filter((entry) => getComputedStyle(entry).display !== "none").length, smallestTarget: Math.min(...Array.from(document.querySelectorAll(".module-nav .mobile-primary-tab")) .filter((entry) => getComputedStyle(entry).display !== "none") .map((entry) => entry.getBoundingClientRect().height)), })); expect(geometry.overflow, `${view} overflows at ${viewport.width}px in ${theme} theme`).toBeLessThanOrEqual(1); expect(geometry.visibleEntries).toBe(5); expect(geometry.smallestTarget).toBeGreaterThanOrEqual(44); } } await page.evaluate(() => openView("ladderView")); await expect(page.locator("#mobileMarketSelector")).toBeVisible(); await expect(page.locator("#mobileMarketViewSelect option")).toHaveCount(12); expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); } }); test("768px boundary uses the compact desktop shell", async ({ page }) => { await page.setViewportSize({ width: 768, height: 900 }); await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.evaluate(() => openView("sentimentCycleView")); await expect(page.locator("body")).not.toHaveClass(/mobile-shell/); const geometry = await page.evaluate(() => { const nav = document.querySelector(".module-nav").getBoundingClientRect(); const main = document.querySelector(".main").getBoundingClientRect(); return { overflow: document.documentElement.scrollWidth - innerWidth, navWidth: nav.width, navRight: nav.right, mainLeft: main.left, mainRight: main.right, }; }); expect(geometry.overflow).toBeLessThanOrEqual(1); expect(geometry.navWidth).toBeGreaterThanOrEqual(44); expect(geometry.navWidth).toBeLessThan(200); expect(Math.abs(geometry.mainLeft - geometry.navRight)).toBeLessThanOrEqual(1); expect(geometry.mainRight).toBeLessThanOrEqual(768); }); test("native dialogs share one lifecycle and success feedback stays content-sized", async ({ page }) => { await mockApplication(page, session("admin", true)); await page.goto("/index.html"); const dialogIds = [ "strategyDrawer", "tradeLogDialog", "watchlistDialog", "heavenReadingDialog", "globalSearchDialog", "entityDetailDialog", "stockDialog", "alertsDialog", "assistantDialog", "settingsDialog", "adminDialog", ]; await page.evaluate(() => { openModalDialog(document.querySelector("#tradeLogDialog")); openModalDialog(document.querySelector("#watchlistDialog")); }); await expect(page.locator("dialog[open]")).toHaveCount(1); await expect(page.locator("#watchlistDialog")).toBeVisible(); await expect(page.locator("#tradeLogDialog")).toBeHidden(); await page.keyboard.press("Escape"); await expect(page.locator("dialog[open]")).toHaveCount(0); await page.locator('[data-view="screenerView"]').first().click(); await page.locator('[data-screener-mode="quant"]').click(); for (const id of dialogIds) { await page.evaluate((dialogId) => openModalDialog(document.getElementById(dialogId)), id); await expect(page.locator(`#${id}`)).toBeVisible(); await expect(page.locator(`#${id} button[aria-label*="关闭"]`).first()).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.locator(`#${id}`)).toBeHidden(); } await page.evaluate(() => showToast("交易记录已保存")); const toastBox = await page.locator("#toast").boundingBox(); expect(toastBox.width).toBeLessThan(260); expect(toastBox.height).toBeLessThan(80); }); test("new review workflows render account-scoped records", 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="screenerView"]').first().click(); await expect(page.locator('[data-screener-step="regime"]')).toHaveAttribute("data-state", "complete"); await page.locator('[data-screener-mode="quant"]').click(); await page.locator("#openStrategyDrawerButton").click(); await expect(page.locator("#strategyDrawer")).toBeVisible(); await expect(page.locator("#strategyNameInput")).toBeFocused(); await page.keyboard.press("Escape"); await expect(page.locator("#strategyDrawer")).toBeHidden(); 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("#reviewDataDate")).toHaveText("2026-07-22"); const reviewJournal = await page.locator("#reviewWorkspaceView .journal-section").boundingBox(); const reviewLeft = await page.locator("#reviewWorkspaceView .review-left-stack").boundingBox(); expect(Math.abs(reviewJournal.width - 360)).toBeLessThanOrEqual(1); expect(reviewJournal.x).toBeGreaterThan(reviewLeft.x + reviewLeft.width - 2); expect(Math.abs((reviewJournal.y + reviewJournal.height) - (reviewLeft.y + reviewLeft.height))).toBeLessThanOrEqual(1); await expect(page.locator("#watchlistTableBody tr")).toHaveCount(1); await expect(page.locator("#watchlistTableBody tr").first().locator("td")).toHaveCount(8); await expect(page.locator("#watchlistTableBody")).toContainText("+1.86"); await expect(page.locator("#watchlistTableBody")).toContainText("+8.92"); await expect(page.locator("#watchlistTableBody")).toContainText("72.4"); await expect(page.locator("#watchlistTableBody")).toContainText("观察承接,不追高"); const watchColumns = await page.locator("#reviewWorkspaceView .review-watchlist-table thead th").evaluateAll((headers) => ({ widths: headers.map((header) => Math.round(header.getBoundingClientRect().width)), labels: headers.map((header) => header.textContent.replace(/[↕▲▼]/g, "").trim()), numericAligned: headers.filter((header) => header.classList.contains("num")).every((header) => getComputedStyle(header).textAlign === "right"), })); expect(watchColumns.labels).toEqual(["标记", "股票", "所属板块", "今日涨幅(%)", "5日涨幅(%)", "竞价关注(分)", "跟踪备注", "操作"]); expect(watchColumns.widths[6]).toBeGreaterThan(watchColumns.widths[3]); expect(watchColumns.numericAligned).toBe(true); await expect(page.locator("#journalSummary")).toHaveValue("缩量修复,主线仍待确认"); await expect(page.locator("#journalContent")).toHaveValue("做对了等待确认。"); await expect(page.locator("#journalPlan")).toHaveValue("只做有承接的核心。"); const journalSpacing = await page.evaluate(() => { const textarea = document.querySelector("#journalContent"); const label = textarea.previousElementSibling; const labelBox = label.getBoundingClientRect(); const textareaBox = textarea.getBoundingClientRect(); return { labelHeight: Math.round(labelBox.height), gap: Math.round(textareaBox.top - labelBox.bottom) }; }); expect(journalSpacing).toEqual({ labelHeight: 18, gap: 6 }); await page.screenshot({ path: "runtime/test-results/review-stage17-1440.png", fullPage: true }); await page.locator("#openWatchlistDialog").click(); await expect(page.locator("#watchlistDialog")).toBeVisible(); await page.locator("#watchlistSearchInput").fill("002141"); await expect(page.locator("#watchlistSearchResults [data-watchlist-result]")).toHaveCount(1); await page.locator("#watchlistSearchResults [data-watchlist-result]").click(); await expect(page.locator("#watchlistSelectionName")).toHaveText("Test Stock"); await page.locator("#watchlistRemark").fill("等待放量确认"); await page.locator("#saveWatchlist").click(); await expect(page.locator("#watchlistDialog")).toBeHidden(); await expect(page.locator("#tradeLogTableBody tr").first().locator("td")).toHaveCount(9); const tradeColumns = await page.locator("#reviewWorkspaceView .trade-log-table thead th").evaluateAll((headers) => ({ widths: headers.map((header) => Math.round(header.getBoundingClientRect().width)), labels: headers.map((header) => header.textContent.replace(/[↕▲▼]/g, "").trim()), })); expect(tradeColumns.labels).toEqual(["日期", "股票", "动作", "仓位(%)", "盈亏(%)", "盈亏金额(元)", "情绪 / 标签", "交易复核", "操作"]); expect(tradeColumns.widths[7]).toBeGreaterThan(tradeColumns.widths[3]); expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1); await expect(page.locator("#reviewHistoryPanel")).toBeHidden(); await page.locator("#reviewHistoryToggle").click(); await expect(page.locator("#reviewHistoryPanel")).toBeVisible(); await expect(page.locator("#reviewHistoryToggle")).toHaveAttribute("aria-expanded", "true"); const reviewHistoryOverflow = await page.evaluate(() => { const history = document.querySelector("#notesHistory"); const seed = history.querySelector(".note-row"); for (let index = 0; index < 18; index += 1) history.appendChild(seed.cloneNode(true)); const main = document.querySelector(".app-main"); return { mainClientHeight: main.clientHeight, mainScrollHeight: main.scrollHeight, mainOverflowY: getComputedStyle(main).overflowY, historyClientHeight: history.clientHeight, historyScrollHeight: history.scrollHeight, historyOverflowY: getComputedStyle(history).overflowY, }; }); expect(reviewHistoryOverflow.mainOverflowY).toBe("auto"); expect(reviewHistoryOverflow.mainScrollHeight).toBeGreaterThan(reviewHistoryOverflow.mainClientHeight); expect(reviewHistoryOverflow.historyOverflowY).toBe("auto"); expect(reviewHistoryOverflow.historyScrollHeight).toBeGreaterThan(reviewHistoryOverflow.historyClientHeight); await expect(page.locator("#tradeLogTableBody tr")).toHaveCount(1); const tradeScroll = await page.evaluate(() => { const seed = state.tradeEntries[0]; state.tradeEntries = Array.from({ length: 24 }, (_, index) => ({ ...seed, id: index + 1 })); renderTradeLog(); const frame = document.querySelector("#reviewWorkspaceView .trade-log-table-frame"); return { scrollHeight: frame.scrollHeight, clientHeight: frame.clientHeight }; }); expect(tradeScroll.scrollHeight).toBeGreaterThan(tradeScroll.clientHeight); 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.setViewportSize({ width: 375, height: 812 }); expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1); const mobileLeft = await page.locator("#reviewWorkspaceView .review-left-stack").boundingBox(); const mobileJournal = await page.locator("#reviewWorkspaceView .journal-section").boundingBox(); expect(mobileJournal.y).toBeGreaterThanOrEqual(mobileLeft.y + mobileLeft.height - 2); await page.setViewportSize({ width: 1440, height: 900 }); 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(); }); test("curated strategies and quant builder form independent screener workspaces", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.locator('[data-view="screenerView"]').first().click(); await page.locator('[data-screener-mode="curated"]').click(); await expect(page.locator('[data-screener-panel="smart"]')).toBeHidden(); await expect(page.locator('[data-screener-panel="curated"]')).toBeVisible(); await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(1); await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量"); await expect(page.locator("#curatedFilterList .curated-rule-row")).toHaveCount(1); await expect(page.locator("#curatedRunButton")).toHaveCount(0); await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4); await page.locator('[data-screener-mode="quant"]').click(); await expect(page.locator('[data-screener-panel="curated"]')).toBeHidden(); await expect(page.locator('[data-screener-panel="quant"]')).toBeVisible(); await expect(page.locator("#quantFilterRows .quant-filter-row")).toHaveCount(2); await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5); await expect(page.locator('#quantScoreRows input[data-quant-key="weight"]').first()).toHaveAttribute("type", "range"); await expect(page.locator("#quantWeightTotal")).toHaveText("100%"); await expect(page.locator("#quantRunButton")).toBeEnabled(); await page.locator("#addQuantFilterButton").click(); await expect(page.locator("#quantFilterRows .quant-filter-row")).toHaveCount(3); const lastScore = page.locator("#quantScoreRows .quant-score-row").last(); await expect(lastScore.locator('[data-quant-action="direction"]')).toHaveText("数值越低越优"); await lastScore.locator('[data-quant-action="direction"]').click(); await expect(lastScore.locator('[data-quant-action="direction"]')).toHaveText("数值越高越优"); }); test("screener refresh restores every mode and deduplicates setup loading", async ({ page }) => { const candidate = (code, name) => ({ code, name, sector: "测试板块", score_display: 80, historical_probability: 50, probability_samples: 20, pct_chg: 1, return_5d: 2, volume_ratio_5d: 1.2, sector_strength: 70, reason: "测试来源", risk_flags: [], }); const result = (mode, runId, strategyName, row) => ({ meta: { run_id: runId, trade_date: "20260722", regime: "repair", strategy_name: strategyName, mode, }, candidates: [row], disclaimer: "历史统计不代表未来收益", backtest: null, }); const options = { latestScreenerResults: { smart: result("smart", 61, "修复确认", candidate("600001", "阶段恢复")), curated: result("curated", 62, "连续分红质量", candidate("600002", "策略恢复")), quant: result("quant", 63, "自定义量化公式", candidate("600003", "量化恢复")), }, }; await mockApplication(page, session("user", true), options); await page.goto("/index.html?view=screenerView"); await expect(page.locator("#screenerTableBody")).toContainText("阶段恢复"); expect(options.screenerSetupRequests).toBe(1); await page.locator('[data-screener-mode="curated"]').click(); await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量"); await expect(page.locator("#screenerTableBody")).toContainText("策略恢复"); await page.locator('[data-screener-mode="quant"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("量化恢复"); await page.reload(); await expect(page.locator('[data-screener-mode="quant"]')).toHaveClass(/active/); await expect(page.locator("#screenerTableBody")).toContainText("量化恢复"); expect(options.screenerSetupRequests).toBe(2); await page.locator("#quantRunButton").click(); await expect.poll(() => options.screenerRunBodies?.length || 0).toBe(1); expect(options.screenerRunBodies[0].mode).toBe("quant"); }); test("screener redesign preserves three clear workspaces across desktop and mobile", 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="screenerView"]').first().click(); await expect(page.locator("#screenerView .screener-page-bar")).toBeVisible(); await expect(page.locator("#screenerView .screener-step")).toHaveCount(4); await expect(page.locator("#regimeLabel")).toHaveText("修复"); await expect(page.locator("#screenerView .regime-temperature")).toHaveCount(0); await expect(page.locator("#regimeEvidenceList")).toContainText("情绪温度"); const overviewCards = page.locator("#screenerView .screener-overview-card"); await expect(overviewCards).toHaveCount(2); const [regimeBox, strategyBox] = await Promise.all([ overviewCards.nth(0).boundingBox(), overviewCards.nth(1).boundingBox(), ]); expect(Math.abs(regimeBox.y - strategyBox.y)).toBeLessThanOrEqual(1); expect(strategyBox.x).toBeGreaterThan(regimeBox.x + regimeBox.width - 2); expect(regimeBox.height).toBeLessThanOrEqual(155); expect(strategyBox.height).toBeLessThanOrEqual(155); for (const line of await page.locator("#screenerView .step-line").all()) { expect((await line.boundingBox()).width).toBeLessThanOrEqual(42); } const [strategyDescriptionBox, strategyActionsBox] = await Promise.all([ page.locator("#activeStrategyDescription").boundingBox(), page.locator(".screener-strategy-actions").boundingBox(), ]); expect(strategyActionsBox.y - (strategyDescriptionBox.y + strategyDescriptionBox.height)).toBeLessThanOrEqual(8); const trackingEntryStyle = await page.locator("#openScreenerTrackingButton").evaluate((element) => { const style = getComputedStyle(element); return { background: style.backgroundColor, weight: style.fontWeight }; }); expect(trackingEntryStyle.background).not.toBe("rgb(255, 255, 255)"); expect(Number(trackingEntryStyle.weight)).toBeGreaterThanOrEqual(700); await page.evaluate(() => { setScreenerResult("smart", { meta: { run_id: 46, trade_date: "20260722", realtime: false, regime: "repair", strategy_name: "修复确认" }, disclaimer: "历史统计不代表未来收益", candidates: [{ code: "600000", ts_code: "600000.SH", name: "浦发银行", sector: "银行", price: 12, score_display: 82, historical_probability: 45, probability_samples: 30, pct_chg: 1.2, return_5d: 3.4, volume_ratio_5d: 1.5, sector_strength: 78, reason: "板块强度、相对强度", risk_flags: [], }], backtest: { samples: 35, win_rate: 8.6, average_3d_return: -5, average_drawdown: -11.71, definition: "收盘后选股,未来3日按统一阈值验证。", }, }, { regime: "repair", strategyId: 1, strategyName: "修复确认" }); renderScreenerResult(); }); const completedMarkers = page.locator('#screenerView .screener-step[data-state="complete"] .step-marker'); await expect(completedMarkers).toHaveCount(3); await expect(page.locator("#backtestPanel")).toBeVisible(); expect((await page.locator("#backtestPanel").boundingBox()).height).toBeLessThanOrEqual(55); await expect(page.locator("#backtestMetrics .dragon-metric span")).toHaveCount(4); await expect(page.locator("#screenerResultSource")).toHaveText("阶段选股 · 修复 · 修复确认"); for (const metric of await page.locator("#backtestMetrics .dragon-metric").all()) { const box = await metric.boundingBox(); expect(box.height).toBeLessThanOrEqual(40); } await page.screenshot({ path: "runtime/test-results/screener-stage15-phase-1440.png", fullPage: true }); await page.evaluate(() => { state.screenerSetup.strategies.push({ id: 4, name: "低波质量", description: "用第二套策略验证整卡选择交互。", regimes: ["repair"], builtin: true, data_ready: true, missing_data: [], formula: { meta: { library: "curated", category: "质量防守", quality: "A", frequency: "月度", risk: "低", data_group: "行情与质量" }, universe: { exclude_st: true, listed_days_min: 720 }, filters: [{ field: "return_20d", op: ">=", value: 0 }], score: [{ field: "relative_strength", weight: 1, direction: "desc" }], limit: 20, min_score: 0.5, }, }); renderCuratedStrategyLibrary(); }); await page.locator('[data-screener-mode="curated"]').click(); await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(2); await expect(page.locator(".curated-detail-pane")).toBeVisible(); const secondStrategy = page.locator('#curatedStrategyList [data-curated-strategy="4"]'); await secondStrategy.click(); await expect(secondStrategy).toHaveClass(/active/); await expect(page.locator('#curatedStrategyList [data-curated-strategy="2"]')).not.toHaveClass(/active/); await expect(page.locator("#curatedStrategyName")).toHaveText("低波质量"); const [libraryBox, detailBox] = await Promise.all([ page.locator(".curated-library-pane").boundingBox(), page.locator(".curated-detail-pane").boundingBox(), ]); expect(detailBox.x).toBeGreaterThan(libraryBox.x + libraryBox.width - 2); expect(Math.abs(detailBox.y - libraryBox.y)).toBeLessThanOrEqual(1); expect(libraryBox.width).toBeLessThan(detailBox.width); await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4); await page.screenshot({ path: "runtime/test-results/screener-stage15-strategy-1440.png", fullPage: true }); await page.locator('[data-screener-mode="quant"]').click(); await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5); await expect(page.locator("#screenerView .quant-intro-band")).toHaveCount(0); await expect(page.getByText("执行设置", { exact: true })).toHaveCount(0); await expect(page.locator("#screenerResultTitle")).toHaveText("自定义选股结果"); const [builderBox, summaryBox] = await Promise.all([ page.locator(".quant-builder-pane").boundingBox(), page.locator(".quant-summary-pane").boundingBox(), ]); expect(Math.abs(builderBox.y - summaryBox.y)).toBeLessThanOrEqual(1); expect(summaryBox.x).toBeGreaterThan(builderBox.x + builderBox.width - 2); expect(builderBox.width).toBeGreaterThanOrEqual(490); expect(builderBox.width).toBeLessThanOrEqual(540); const quantRunBox = await page.locator("#quantRunButton").boundingBox(); expect(quantRunBox.width).toBeLessThan(180); expect((await page.locator("#quantFilterRows .quant-filter-row select").first().boundingBox()).width).toBeLessThanOrEqual(225); await expect(page.locator('[data-screener-results-slot="quant"] > .screener-results-view')).toBeVisible(); await page.screenshot({ path: "runtime/test-results/screener-stage15-quant-1440.png", fullPage: true }); await page.setViewportSize({ width: 375, height: 812 }); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); expect(overflow).toBeLessThanOrEqual(1); await expect(page.locator("#screenerView .screener-mode-tabs")).toBeVisible(); await page.locator('[data-screener-mobile-view="results"]').click(); await expect(page.locator("#screenerView .screener-results-view")).toBeVisible(); await expect(page.locator("#screenerEmpty")).toBeVisible(); }); test("automatic screener results stay read-only and mode results stay isolated", 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="screenerView"]').first().click(); await page.evaluate(() => { state.screenerSetup.regimes.push({ id: "retreat", label: "退潮" }); state.screenerSetup.strategies.push({ id: 3, name: "退潮防守", description: "仅保留抗跌方向", regimes: ["retreat"], builtin: true, data_ready: true, missing_data: [], formula: { meta: { library: "smart" }, universe: {}, filters: [], score: [], limit: 10, min_score: 0.5 }, }); const candidate = (code, name) => ({ code, name, sector: "测试板块", score_display: 80, historical_probability: 50, probability_samples: 20, pct_chg: 1, return_5d: 2, volume_ratio_5d: 1.2, sector_strength: 70, reason: "测试来源", risk_flags: [], }); window.__screenerCandidate = candidate; setScreenerResult("smart", { meta: { run_id: 51, trade_date: "20260722", regime: "repair", strategy_name: "修复确认" }, disclaimer: "历史统计不代表未来收益", candidates: [candidate("600001", "阶段结果")], backtest: { samples: 37, win_rate: 50, average_3d_return: 1, average_drawdown: -2, definition: "测试" }, }, { regime: "repair", strategyId: 1, strategyName: "修复确认" }); renderScreenerSetup(); }); await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(3); await expect(page.locator("#screenerTableBody")).toContainText("阶段结果"); await expect(page.locator('[data-regime]')).toHaveCount(0); await expect(page.locator("#screenerRunButton")).toHaveCount(0); await expect(page.locator("#syncScreenerButton")).toHaveCount(0); await expect(page.locator("#changeStrategyButton")).toHaveCount(0); await expect(page.locator("#editStrategyButton")).toHaveCount(0); await page.locator('[data-screener-mode="curated"]').click(); await expect(page.locator("#screenerEmpty")).toContainText("尚无成功发布"); await page.evaluate(() => { setScreenerResult("curated", { meta: { run_id: 52, trade_date: "20260722", regime: "repair", strategy_name: "连续分红质量" }, disclaimer: "历史统计不代表未来收益", candidates: [window.__screenerCandidate("600002", "策略结果")], }, { regime: "repair", strategyId: 2, strategyName: "连续分红质量" }); renderScreenerResult(); }); await expect(page.locator("#screenerTableBody")).toContainText("策略结果"); await expect(page.locator("#screenerTableBody")).not.toContainText("阶段结果"); await expect(page.locator("#screenerResultSource")).toHaveText("策略选股 · 连续分红质量"); await page.locator('[data-screener-mode="quant"]').click(); await expect(page.locator("#screenerEmpty")).toContainText("自定义选股"); await page.evaluate(() => { setScreenerResult("quant", { meta: { run_id: 53, trade_date: "20260722", regime: "repair", strategy_name: "自定义量化公式" }, disclaimer: "历史统计不代表未来收益", candidates: [window.__screenerCandidate("600003", "量化结果")], }, { regime: "repair", strategyName: "自定义量化公式" }); renderScreenerResult(); }); await expect(page.locator("#screenerTableBody")).toContainText("量化结果"); await expect(page.locator("#screenerResultSource")).toHaveText("自定义选股 · 自定义因子权重"); await page.locator('[data-screener-mode="smart"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("阶段结果"); await expect(page.locator("#screenerTableBody")).not.toContainText("策略结果"); await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果"); }); test("screener publishes atomically and exposes latest, active, and historical candidates", async ({ page }) => { const candidate = { code: "600001", ts_code: "600001.SH", name: "已发布候选", sector: "电力设备", price: 12, score_display: 82, historical_probability: 45, probability_samples: 30, pct_chg: 1.2, return_5d: 3.4, volume_ratio_5d: 1.5, sector_strength: 78, reason: "板块强度、相对强度", risk_flags: [], }; const smartResult = { meta: { run_id: 201, trade_date: "20260803", regime: "repair", strategy_name: "修复确认", mode: "smart", }, candidates: [candidate], disclaimer: "历史统计不代表未来收益", backtest: null, }; const activeRow = { mode: "smart", code: candidate.code, name: candidate.name, sector: candidate.sector, selection_date: "20260803", score_display: candidate.score_display, hits: [{ strategy_name: "修复确认", selection_date: "20260803", active: true, validity: { label: "阶段内有效" }, valid_until: "", remaining_trading_days: null, score_display: candidate.score_display, }], }; const expiredRow = { mode: "smart", code: "600002", name: "历史候选", sector: "银行", selection_date: "20260727", score_display: 76, hits: [{ strategy_name: "修复确认", selection_date: "20260727", active: false, validity: { label: "阶段内有效" }, valid_until: "20260801", remaining_trading_days: 0, score_display: 76, }], }; const options = { latestScreenerResults: { smart: smartResult }, recentScreenerResults: [smartResult], screenerPublishedRuns: { 修复确认: { trade_date: "20260803", status: "ready", detail: "1 只候选" }, 连续分红质量: { trade_date: "20260803", status: "no_signal", detail: "数据完整,暂无符合条件个股" }, 数据缺失策略: { trade_date: "20260803", status: "missing_data", detail: "缺少近5日资金流" }, }, additionalScreenerStrategies: [{ id: 4, name: "数据缺失策略", description: "验证数据不足状态", regimes: ["repair"], builtin: true, data_ready: false, missing_data: ["近5日资金流"], formula: { meta: { library: "curated", category: "资金动量", quality: "A", frequency: "每日", risk: "中" }, universe: { exclude_st: true }, filters: [], score: [], limit: 20, min_score: 0.5, }, }], screenerSetupOverride: { trade_date: "20260804", requested_trade_date: "20260804", published_batch: { trade_date: "20260803", status: "complete", is_fallback: true, completed_count: 28, skipped_count: 1, finished_at: "2026-08-03T15:30:00+08:00", notice: "所选日期候选正在生成,当前保留上一成功批次", }, automatic_status: { trade_date: "20260804", status: "running", retaining_trade_date: "20260803", }, active_signals: [activeRow], candidate_history: [activeRow, expiredRow], }, }; await page.setViewportSize({ width: 1920, height: 1080 }); await mockApplication(page, session("user", true), options); await page.goto("/index.html?view=screenerView"); await expect(page.locator("#screenerDateLabel")).toContainText("候选批次 2026-08-03"); await expect(page.locator("#screenerBatchNotice")).toContainText("当前保留上一成功批次"); await expect(page.locator("#screenerTableBody")).toContainText(candidate.name); await page.locator('[data-screener-archive-view="active"]').click(); await expect(page.locator("#screenerResultTitle")).toHaveText("持续有效信号"); await expect(page.locator("#screenerArchiveTableBody")).toContainText(candidate.name); await expect(page.locator("#screenerArchiveTableBody")).not.toContainText("历史候选"); await page.locator('[data-screener-archive-view="history"]').click(); await expect(page.locator("#screenerResultTitle")).toHaveText("历史入选记录"); await expect(page.locator("#screenerArchiveTableBody")).toContainText(candidate.name); await expect(page.locator("#screenerArchiveTableBody")).toContainText("历史候选"); await page.locator('[data-screener-mode="curated"]').click(); await expect(page.locator("#screenerEmpty")).toHaveText("数据完整,暂无符合条件个股"); await expect(page.locator("#screenerTableBody")).not.toContainText(candidate.name); await page.locator('[data-curated-strategy="4"]').click(); await expect(page.locator("#screenerEmpty")).toContainText("缺少必需数据"); await expect(page.locator("#curatedDataStatus")).toContainText("缺少近5日资金流"); await page.locator('[data-screener-mode="smart"]').click(); await page.reload(); await expect(page.locator("#screenerTableBody")).toContainText(candidate.name); await expect(page.locator("#screenerBatchNotice")).toContainText("当前保留上一成功批次"); expect(options.screenerSetupRequests).toBe(2); await page.setViewportSize({ width: 390, height: 844 }); await page.locator('[data-screener-mobile-view="results"]').click(); await expect(page.locator("#screenerView .screener-results-view")).toBeVisible(); await expect(page.locator("#screenerTableBody")).toContainText(candidate.name); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); expect(overflow).toBeLessThanOrEqual(1); await page.locator("#headerMenuButton").click(); await page.locator('[data-mobile-command-target="themeToggle"]').click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); await expect(page.locator("#screenerView .screener-results-view")).toBeVisible(); await page.locator("#headerMenuButton").click(); await page.locator('[data-mobile-command-target="themeToggle"]').click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); }); test("screener restores automatic stage and curated pools across switching and reload", async ({ page }) => { const formula = { meta: { library: "smart" }, universe: {}, filters: [], score: [{ field: "relative_strength", weight: 1, direction: "desc" }], limit: 10, min_score: 0.5, }; const candidate = (code, name) => ({ code, name, sector: "Test Sector", score_display: 80, historical_probability: 50, probability_samples: 20, pct_chg: 1, return_5d: 2, volume_ratio_5d: 1.2, sector_strength: 70, reason: "Context result", risk_flags: [], }); const result = (mode, runId, strategyName, row) => ({ meta: { run_id: runId, trade_date: "20260722", regime: "repair", strategy_name: strategyName, mode, }, candidates: [row], disclaimer: "Historical statistics do not predict future returns.", backtest: null, }); const smartResult = result("smart", 101, "修复确认", candidate("600001", "Smart Repair")); const curatedA = result("curated", 102, "连续分红质量", candidate("600002", "Curated A")); const curatedB = result("curated", 103, "Quality B", candidate("600003", "Curated B")); const options = { latestScreenerResults: { smart: smartResult, curated: curatedA }, recentScreenerResults: [smartResult, curatedA, curatedB], additionalScreenerStrategies: [ { id: 4, name: "Quality B", description: "Second curated strategy", regimes: ["repair"], builtin: true, data_ready: true, missing_data: [], formula: { ...formula, meta: { library: "curated", category: "Quality", quality: "A", frequency: "Monthly", risk: "Low" }, }, }, ], }; await mockApplication(page, session("user", true), options); await page.goto("/index.html?view=screenerView"); await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair"); await expect(page.locator("#screenerRunButton")).toHaveCount(0); await page.locator('[data-screener-mode="curated"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("Curated A"); await page.locator('[data-curated-strategy="4"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("Curated B"); await page.locator('[data-curated-strategy="2"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("Curated A"); expect(options.screenerRunBodies || []).toHaveLength(0); await page.reload(); await page.locator('[data-screener-mode="smart"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair"); await page.locator('[data-screener-mode="curated"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("Curated A"); await page.locator('[data-curated-strategy="4"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("Curated B"); }); test("screener tracking is an internal page populated only by manual candidate actions", 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="screenerView"]').first().click(); await page.evaluate(() => { setScreenerResult("smart", { meta: { run_id: 45, trade_date: "20260722", realtime: false, regime: "repair", strategy_name: "修复确认" }, disclaimer: "历史统计不代表未来收益", candidates: [{ code: "600000", ts_code: "600000.SH", name: "浦发银行", sector: "银行", price: 12, score_display: 82, historical_probability: 45, probability_samples: 30, pct_chg: 1.2, return_5d: 3.4, volume_ratio_5d: 1.5, sector_strength: 78, reason: "板块强度、相对强度", risk_flags: [], }], }, { regime: "repair", strategyId: 1, strategyName: "修复确认" }); renderScreenerResult(); }); const addButton = page.locator('[data-add-tracking="600000"]'); await expect(addButton).toHaveText("加入跟踪"); await addButton.click(); await expect(page.locator('[data-add-tracking="600000"]')).toHaveText("已跟踪"); await page.locator("#openScreenerTrackingButton").click(); await expect(page.locator("#screenerTrackingView")).toHaveClass(/active-view/); await expect(page.locator('[data-view="screenerView"]').first()).toHaveClass(/active/); await expect(page.locator("#trackingTableBody tr")).toHaveCount(2); await expect(page.locator('[data-view="screenerTrackingView"]')).toHaveCount(0); await page.screenshot({ path: "runtime/test-results/screener-stage15-tracking-1440.png", fullPage: true }); page.once("dialog", (dialog) => dialog.accept()); await page.locator('[data-remove-tracking="10"]').click(); await expect(page.locator("#trackingTableBody tr")).toHaveCount(1); await page.locator("#closeScreenerTrackingButton").click(); await expect(page.locator("#screenerView")).toHaveClass(/active-view/); }); 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?ui=desktop"); 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 .wt-title-line h1").evaluate((element) => getComputedStyle(element).fontSize)); expect(titleSize).toBeLessThanOrEqual(32); 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(1120); expect(readingDialog.width / readingDialog.height).toBeGreaterThan(1.4); 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?ui=desktop"); 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(); const heartPanel = await page.locator("#heavenHeartPanel").boundingBox(); expect(heartControls.width).toBeLessThanOrEqual(heartPanel.width); expect(heartControls.x).toBeGreaterThanOrEqual(heartPanel.x - 1); 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("#mentorView .mentor-sidebar .mentor-directory-tools")).toBeVisible(); await expect(page.locator("#mentorFilterToggle")).toBeVisible(); 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')).toHaveCount(0); 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("#mentorFilterToggle").click(); await page.locator('[data-mentor-grade="B"]').click(); await expect(page.locator("#mentorFilterOptions [data-mentor-grade].active")).toHaveText("B级"); 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("#activeMentorStatus")).toContainText("确认之后再行动"); const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox(); const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox(); const mentorInput = await page.locator("#mentorQuestion").boundingBox(); const mentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox(); expect(Math.abs(mentorLibrary.width - 300)).toBeLessThanOrEqual(1); expect(Math.abs(mentorChat.x - (mentorLibrary.x + mentorLibrary.width))).toBeLessThanOrEqual(1); expect(Math.abs(mentorChat.x + mentorChat.width - (mentorLayout.x + mentorLayout.width))).toBeLessThanOrEqual(1); const composerForm = await page.locator("#mentorView .mentor-chat-form").boundingBox(); expect(composerForm.height).toBeGreaterThanOrEqual(81); expect(composerForm.height).toBeLessThanOrEqual(91); expect(mentorInput.height).toBeGreaterThanOrEqual(24); expect(mentorInput.height).toBeLessThanOrEqual(34); const searchField = await page.locator("#mentorView .mentor-search-field").boundingBox(); expect(searchField.width).toBeGreaterThan(160); await expect(page.locator("#mentorSearchInput")).toHaveAttribute("placeholder", "搜索联系人或标签"); await expect(page.locator(".module-nav")).toBeVisible(); await expect(page.locator(".module-tab.active")).toHaveAttribute("data-view", "mentorView"); await expect(page.locator(".market-tape")).toBeVisible(); await expect(page.locator("#tradeDate")).toBeVisible(); await expect(page.locator(".overview-strip")).toBeVisible(); await expect(page.locator(".status-bar")).toBeVisible(); await expect(page.locator("#themeToggle")).toBeVisible(); await expect(page.locator("#mentorView .mentor-page-title h2")).toHaveText("问师"); await expect(page.locator("#mentorPageSubtitle")).toContainText("数据日期"); expect(await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)).toBeLessThanOrEqual(1); await page.setViewportSize({ width: 1920, height: 947 }); const expandedMentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox(); const footerBar = await page.locator("#mentorView .mentor-workspace-footer").boundingBox(); const shellStatusBar = await page.locator(".status-bar").boundingBox(); expect(Math.abs(shellStatusBar.y + shellStatusBar.height - 947)).toBeLessThanOrEqual(1); expect(footerBar.y + footerBar.height).toBeLessThanOrEqual(shellStatusBar.y); expect(Math.abs(expandedMentorLayout.y + expandedMentorLayout.height - footerBar.y)).toBeLessThanOrEqual(1); expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1); expect(await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)).toBeLessThanOrEqual(1); }); test("mentor stays inside the project shell and switching pages leaves no residue", 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("#mentorView")).toHaveClass(/active-view/); await expect(page.locator(".module-nav")).toBeVisible(); await expect(page.locator(".module-tab.active")).toHaveAttribute("data-view", "mentorView"); await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22); await expect(page.locator("#mentorView .mentor-page-title h2")).toHaveText("问师"); await page.locator('[data-view="limitPool"]').first().click(); await expect(page.locator("#limitPool")).toHaveClass(/active-view/); await expect(page.locator("#mentorView")).not.toHaveClass(/active-view/); await expect(page.locator("#mentorView")).not.toBeVisible(); await expect(page.locator(".module-nav")).toBeVisible(); await expect(page.locator(".module-tab.active")).toHaveAttribute("data-view", "limitPool"); await page.locator('[data-view="mentorView"]').first().click(); await expect(page.locator("#mentorView")).toHaveClass(/active-view/); await expect(page.locator(".module-nav")).toBeVisible(); await expect(page.locator(".module-tab.active")).toHaveAttribute("data-view", "mentorView"); await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22); await expect(page.locator("#mentorPageSubtitle")).toContainText("数据日期"); expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1); }); test("mentor keeps two columns and opens the profile floating dialog on desktop", 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 page.locator('#mentorList [data-mentor-id="source-b"]').click(); await expect(page.locator("#mentorView .mentor-profile-panel")).toHaveCount(0); const mentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox(); const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox(); const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox(); expect(Math.abs(mentorChat.x - (mentorLibrary.x + mentorLibrary.width))).toBeLessThanOrEqual(1); expect(Math.abs(mentorChat.x + mentorChat.width - (mentorLayout.x + mentorLayout.width))).toBeLessThanOrEqual(1); expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1); await page.locator("#mentorProfileButton").click(); await expect(page.locator("#mentorProfileDialog")).toBeVisible(); await expect(page.locator("#mentorProfileDialogName")).toHaveText("多源老师"); await expect(page.locator("#mentorProfileDialogEvidence")).toHaveText("公开访谈与多源材料"); await page.locator('[data-mentor-dialog-close="mentorProfileDialog"]').click(); await expect(page.locator("#mentorProfileDialog")).not.toBeVisible(); }); 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-id="source-c"]').click(); await page.locator("#mentorPinButton").click(); await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-c"); await expect(page.locator('#mentorList [data-mentor-card="source-c"] .mentor-badge.pinned')).toHaveText("置顶"); await page.locator('[data-mentor-id="source-b"]').click(); await page.locator("#mentorPinButton").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); const followUps = answer.locator("[data-mentor-follow-up]"); await expect(followUps).toHaveCount(3); await expect(followUps.first().locator(".lucide")).toHaveCount(0); await expect(answer.locator("small")).toHaveCount(0); const messageCount = await page.locator("#mentorMessages .mentor-message").count(); await followUps.first().click(); await expect(page.locator("#mentorQuestion")).toHaveValue("哪些信号代表确认?"); await expect(page.locator("#mentorMessages .mentor-message")).toHaveCount(messageCount); const userLabel = page.locator("#mentorMessages .mentor-message.user .mentor-message-label").first(); await expect(userLabel).not.toContainText("我 ·"); const assistantBody = await page.locator("#mentorMessages .mentor-message.assistant .mentor-message-body").last().boundingBox(); expect(assistantBody.width).toBeLessThanOrEqual(900); await page.locator("#themeToggle").click(); const userMessage = page.locator("#mentorMessages .mentor-message.user"); const darkUserMessageStyle = await userMessage.evaluate((element) => ({ background: getComputedStyle(element).backgroundColor, border: getComputedStyle(element).borderTopColor, contentBackground: getComputedStyle(element.querySelector(".mentor-message-content")).backgroundColor, contentColor: getComputedStyle(element.querySelector(".mentor-message-content")).color, })); expect(darkUserMessageStyle.background).not.toBe("rgb(255, 255, 255)"); expect(darkUserMessageStyle.border).not.toBe("rgb(255, 255, 255)"); expect(darkUserMessageStyle.contentBackground).toBe("rgb(53, 89, 140)"); expect(darkUserMessageStyle.contentColor).toBe("rgb(234, 241, 251)"); const darkMessageStyle = await answer.evaluate((element) => { const style = getComputedStyle(element); const content = element.querySelector(".mentor-message-content"); const heading = element.querySelector(".mentor-answer-heading"); const label = element.querySelector(".mentor-message-label"); return { background: style.backgroundColor, border: style.borderTopColor, shadow: style.boxShadow, contentColor: getComputedStyle(content).color, headingColor: getComputedStyle(heading).color, labelColor: getComputedStyle(label).color, }; }); expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)"); expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)"); expect(darkMessageStyle.shadow).toBe("none"); expect(darkMessageStyle.contentColor).toBe("rgb(232, 234, 237)"); expect(darkMessageStyle.headingColor).toBe("rgb(232, 234, 237)"); expect(darkMessageStyle.labelColor).toBe("rgb(124, 130, 138)"); }); test("mentor avatars map per contact id and follow the final day/night palette", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); const mentors = [ { id: "xiaobai-perspective", name: "小白", description: "个人复盘记录蒸馏", tagline: "复盘自己", focus: ["个人复盘"], evidence: { grade: "A", label: "私有原始语料", note: "私人语料" }, quality: {}, private: true, }, { id: "kobe92-perspective", name: "52科比", description: "情绪周期心法", tagline: "先看周期", focus: ["情绪周期"], evidence: { grade: "A", label: "心法文本", note: "心法" }, quality: {}, private: false, }, { id: "beijingchaojia-perspective", name: "北京炒家", description: "实盘记录", tagline: "实盘为先", focus: ["实盘"], evidence: { grade: "A", label: "实盘资料", note: "实盘" }, quality: {}, private: false, }, { id: "chaojiyangjia-perspective", name: "炒股养家", description: "原始语料", tagline: "情绪为上", focus: ["情绪"], evidence: { grade: "B", label: "原始语料", note: "语料" }, quality: {}, private: false, }, ]; await mockApplication(page, session("admin", true), { mentors }); await page.goto("/index.html"); await page.locator('[data-view="mentorView"]').first().click(); const expectations = { "xiaobai-perspective": ["mentor-avatar-tone-violet", "rgb(238, 236, 253)", "rgb(106, 92, 245)"], "kobe92-perspective": ["mentor-avatar-tone-blue", "rgb(227, 240, 255)", "rgb(51, 112, 255)"], "beijingchaojia-perspective": ["mentor-avatar-tone-green", "rgb(221, 245, 229)", "rgb(46, 164, 79)"], "chaojiyangjia-perspective": ["mentor-avatar-tone-orange", "rgb(253, 238, 221)", "rgb(217, 122, 27)"], }; for (const [id, [tone, background, ink]] of Object.entries(expectations)) { const avatar = page.locator(`#mentorList [data-mentor-id="${id}"] .mentor-avatar`); await expect(avatar).toHaveClass(new RegExp(tone)); const palette = await avatar.evaluate((element) => { const style = getComputedStyle(element); return { background: style.backgroundColor, color: style.color }; }); expect(palette.background).toBe(background); expect(palette.color).toBe(ink); } await page.locator('[data-mentor-id="kobe92-perspective"]').click(); await expect(page.locator("#activeMentorAvatar")).toHaveClass(/mentor-avatar-tone-blue/); await page.locator("#themeToggle").click(); const nightPalette = await page.locator('#mentorList [data-mentor-id="beijingchaojia-perspective"] .mentor-avatar') .evaluate((element) => { const style = getComputedStyle(element); return { background: style.backgroundColor, color: style.color }; }); expect(nightPalette.background).toBe("rgb(35, 74, 56)"); expect(nightPalette.color).toBe("rgb(95, 206, 143)"); await expect(page.locator("#activeMentorAvatar")).toHaveClass(/mentor-avatar-tone-blue/); }); test("mentor floating dialogs open centered, save notes per account, and keep composer one-line", 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 page.locator('#mentorList [data-mentor-id="source-b"]').click(); await page.locator("#mentorNoteButton").click(); await expect(page.locator("#mentorNoteDialog")).toBeVisible(); const noteDialog = await page.locator("#mentorNoteDialog").boundingBox(); expect(noteDialog.width).toBeLessThanOrEqual(400); expect(Math.abs(noteDialog.x + noteDialog.width / 2 - 720)).toBeLessThanOrEqual(3); expect(Math.abs(noteDialog.y + noteDialog.height / 2 - 450)).toBeLessThanOrEqual(3); await page.locator("#mentorNoteInput").fill("多源老师:确认后再行动"); await expect(page.locator("#mentorNoteInput")).toHaveValue("多源老师:确认后再行动"); await page.locator('[data-mentor-dialog-close="mentorNoteDialog"]').click(); await expect(page.locator("#mentorNoteDialog")).not.toBeVisible(); await page.locator("#mentorNoteButton").click(); await expect(page.locator("#mentorNoteInput")).toHaveValue("多源老师:确认后再行动"); await page.locator('[data-mentor-dialog-close="mentorNoteDialog"]').click(); await page.locator("#mentorProfileButton").click(); await expect(page.locator("#mentorProfileDialog")).toBeVisible(); await expect(page.locator("#mentorProfileDialogName")).toHaveText("多源老师"); await expect(page.locator("#mentorProfileDialogEvidence")).toHaveText("公开访谈与多源材料"); const profileDialog = await page.locator("#mentorProfileDialog").boundingBox(); expect(profileDialog.width).toBeLessThanOrEqual(400); expect(Math.abs(profileDialog.x + profileDialog.width / 2 - 720)).toBeLessThanOrEqual(3); await page.locator('[data-mentor-dialog-close="mentorProfileDialog"]').click(); await expect(page.locator("#mentorProfileDialog")).not.toBeVisible(); const disclaimerStyle = await page.locator("#mentorView .mentor-disclaimer").evaluate((el) => ({ align: getComputedStyle(el).textAlign, })); expect(disclaimerStyle.align).toBe("center"); const composerHint = page.locator("#mentorView .mentor-composer-hint"); await expect(composerHint).toHaveCount(1); await expect(composerHint).toBeVisible(); const composerInput = page.locator("#mentorQuestion"); const oneLineHeight = (await composerInput.boundingBox()).height; expect(oneLineHeight).toBeGreaterThanOrEqual(24); expect(oneLineHeight).toBeLessThanOrEqual(34); await composerInput.fill("第一行\n第二行"); const grownHeight = (await composerInput.boundingBox()).height; expect(grownHeight).toBeGreaterThan(oneLineHeight + 8); await composerInput.press("Shift+Enter"); await composerInput.type("第三行"); await composerInput.fill("只发送这一行"); await page.locator("#sendMentorQuestion").click(); await expect(page.locator("#mentorMessages .mentor-message.user").last()).toContainText("只发送这一行"); const resetHeight = (await composerInput.boundingBox()).height; expect(resetHeight).toBeLessThanOrEqual(34); }); test("global dialogs share the stage 18 geometry without changing account or admin access", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await mockApplication(page, session("admin", true)); await page.goto("/index.html"); await page.keyboard.press("Control+K"); await expect(page.locator("#globalSearchDialog")).toHaveAttribute("aria-labelledby", "globalSearchTitle"); expect((await page.locator("#globalSearchDialog").boundingBox()).width).toBeLessThanOrEqual(662); await page.locator("#closeGlobalSearch").click(); await page.evaluate(() => openStock("002141", { code: "002141", name: "贤丰控股", sector: "元件" })); const stockGeometry = await page.locator("#stockDialog").evaluate((dialog) => ({ width: dialog.getBoundingClientRect().width, height: dialog.getBoundingClientRect().height, overflowY: getComputedStyle(dialog).overflowY, headerPosition: getComputedStyle(dialog.querySelector(".dialog-header")).position, documentOverflow: document.documentElement.scrollWidth - window.innerWidth, })); expect(stockGeometry.width).toBeLessThanOrEqual(812); expect(stockGeometry.height).toBeLessThanOrEqual(878); expect(stockGeometry.overflowY).toBe("auto"); expect(stockGeometry.headerPosition).toBe("sticky"); expect(stockGeometry.documentOverflow).toBeLessThanOrEqual(1); await page.locator("#closeStockDialog").click(); await page.locator("#alertButton").click(); await expect(page.locator("#alertsDialog")).toHaveAttribute("aria-labelledby", "alertsDialogTitle"); expect((await page.locator("#alertsDialog").boundingBox()).width).toBeLessThanOrEqual(722); await page.locator("#closeAlertsDialog").click(); await page.locator("#assistantButton").click(); const assistantGeometry = await page.locator("#assistantDialog").evaluate((dialog) => ({ height: dialog.getBoundingClientRect().height, overflowY: getComputedStyle(dialog).overflowY, contentDisplay: getComputedStyle(dialog.querySelector(".assistant-member-content")).display, })); expect(assistantGeometry.height).toBeLessThanOrEqual(762); expect(assistantGeometry.overflowY).toBe("hidden"); expect(assistantGeometry.contentDisplay).toBe("flex"); await page.locator("#closeAssistantDialog").click(); await openHeaderCommandMenu(page); await page.locator("#accountButton").click(); await page.locator('[data-account-panel="membership"]').click(); await expect(page.locator("#settingsDialog")).toHaveAttribute("aria-labelledby", "accountDialogTitle"); await expect(page.locator("#accountDialogTitle")).toHaveText("会员状态"); const accountBox = await page.locator("#settingsDialog").boundingBox(); expect(accountBox.width).toBeLessThanOrEqual(722); expect(Math.abs(accountBox.x + accountBox.width / 2 - 720)).toBeLessThanOrEqual(2); expect(Math.abs(accountBox.y + accountBox.height / 2 - 450)).toBeLessThanOrEqual(2); await page.locator("#closeSettingsDialog").click(); await openHeaderCommandMenu(page); await page.locator("#settingsButton").click(); await expect(page.locator("#adminDialog")).toHaveAttribute("aria-labelledby", "adminDialogTitle"); await expect(page.locator("#adminSectionSelect")).toBeVisible(); const adminBox = await page.locator("#adminDialog").boundingBox(); expect(adminBox.width).toBeLessThanOrEqual(902); expect(Math.abs(adminBox.x + adminBox.width / 2 - 720)).toBeLessThanOrEqual(2); expect(Math.abs(adminBox.y + adminBox.height / 2 - 450)).toBeLessThanOrEqual(2); await page.locator("#closeAdminDialog").click(); await page.setViewportSize({ width: 375, height: 812 }); await page.locator("#headerMenuButton").click(); await page.locator('[data-mobile-command-target="alertButton"]').click(); const mobileGeometry = await page.locator("#alertsDialog").evaluate((dialog) => ({ width: dialog.getBoundingClientRect().width, documentOverflow: document.documentElement.scrollWidth - window.innerWidth, })); expect(mobileGeometry.width).toBeLessThanOrEqual(375); expect(mobileGeometry.documentOverflow).toBeLessThanOrEqual(1); }); test("merged header keeps date, detail fields and 13 pool columns after dual-review rework", async ({ page }) => { const previousLimits = dashboard.limits; dashboard.limits = [{ code: "002141", name: "贤丰控股", streak: 5, change: 10.02, 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: "板块龙头连板打开空间", }]; await mockApplication(page, session("admin", true)); await page.goto("/index.html"); dashboard.limits = previousLimits; await page.waitForTimeout(400); await expect(page.locator("#currentPageSubtitle")).toContainText("市场复盘 ·"); await expect(page.locator("#headerMenuButton")).toBeHidden(); await expect(page.locator("#headerCommandGroup")).toBeVisible(); await expect(page.locator("#themeToggle")).toBeVisible(); await expect(page.locator("#refreshButton")).toBeVisible(); await page.locator('[data-view="limitPool"]').first().click(); await expect(page.locator("#currentPageSubtitle")).toContainText("市场复盘 ·"); await expect(page.locator("#currentPageSubtitle")).not.toHaveText("市场复盘"); await page.setViewportSize({ width: 1280, height: 800 }); await expect(page.locator(".tape-optional").first()).toBeHidden(); await page.locator("#overviewToggle").click(); const detailText = await page.locator(".tape-detail").innerText(); for (const label of ["市场情绪", "上涨家数", "下跌家数", "涨停", "跌停", "炸板", "封板率", "两市成交", "数据日期"]) { expect(detailText).toContain(label); } expect(detailText).not.toContain("涨停 / 跌停"); await expect(page.locator("#tapeLimitDown")).toBeVisible(); await expect(page.locator("#detailBroken")).toBeVisible(); await expect(page.locator("#dataDateMetric")).toBeVisible(); await expect(page.locator(".tape-detail-date")).toBeVisible(); expect(await page.locator("#dataDateMetric").evaluate((node) => Boolean(node.closest(".metric-wide")))).toBe(false); await page.keyboard.press("Escape"); const measurePoolTable = () => { const wrap = document.querySelector("#limitPool .pool-table-card"); const table = document.querySelector("#limitTable"); const reason = document.querySelector("#limitTable .reason-column"); const reasonCell = document.querySelector("#limitTable .pool-reason-cell"); const headerCell = document.querySelector("#limitPool .data-table thead th"); const rowCell = document.querySelector("#limitPool .data-table tbody td"); const cells = [...document.querySelectorAll("#limitTable tbody td")]; const numberCells = [...document.querySelectorAll("#limitTable tbody td.num, #limitTable tbody td.number")]; const header = document.querySelector(".app-header"); const headerStyle = headerCell ? getComputedStyle(headerCell) : null; const rowStyle = rowCell ? getComputedStyle(rowCell) : null; const widths = cells.map((cell) => cell.getBoundingClientRect().width); const numberWidths = numberCells.map((cell) => cell.getBoundingClientRect().width); return { tableLayout: table ? getComputedStyle(table).tableLayout : "", columnCount: document.querySelectorAll("#limitTable thead th").length, minCellWidth: widths.length ? Math.min(...widths) : 0, minNumberWidth: numberWidths.length ? Math.min(...numberWidths) : 0, wrapOverflow: wrap ? wrap.scrollWidth - wrap.clientWidth : 0, tableWidth: table ? table.getBoundingClientRect().width : 0, wrapWidth: wrap ? wrap.getBoundingClientRect().width : 0, cardOverflow: wrap ? wrap.scrollWidth - wrap.clientWidth : 0, reasonVisible: Boolean(reason && wrap && reason.getBoundingClientRect().right <= wrap.getBoundingClientRect().right + 1), reasonText: reason ? reason.textContent.trim() : "", reasonTitle: reasonCell?.getAttribute("title") || "", headerOverflow: header ? header.scrollWidth - header.clientWidth : 0, headerHeight: headerStyle ? Number.parseFloat(headerStyle.height) : 0, headerFont: headerStyle ? Number.parseFloat(headerStyle.fontSize) : 0, rowHeight: rowStyle ? Number.parseFloat(rowStyle.height) : 0, }; }; const pool1280 = await page.evaluate(measurePoolTable); expect(pool1280.columnCount).toBe(13); expect(pool1280.tableLayout).toBe("auto"); expect(pool1280.minCellWidth).toBeGreaterThanOrEqual(40); expect(pool1280.minNumberWidth).toBeGreaterThanOrEqual(50); expect(pool1280.wrapOverflow).toBeGreaterThan(0); expect(pool1280.reasonTitle).toContain("板块龙头连板打开空间"); expect(pool1280.headerHeight).toBe(36); expect(pool1280.rowHeight).toBeGreaterThanOrEqual(40); await page.setViewportSize({ width: 1600, height: 1000 }); await page.locator('[data-view="limitPool"]').first().click(); const tableFit = await page.evaluate(measurePoolTable); expect(tableFit.reasonText).toContain("涨停原因"); expect(tableFit.reasonVisible).toBe(true); expect(tableFit.cardOverflow).toBeLessThanOrEqual(1); expect(tableFit.headerOverflow).toBeLessThanOrEqual(1); expect(tableFit.reasonTitle).toContain("板块龙头连板打开空间"); expect(tableFit.headerHeight).toBe(36); expect(tableFit.headerFont).toBe(12.5); expect(tableFit.rowHeight).toBeGreaterThanOrEqual(40); expect(tableFit.tableLayout).toBe("fixed"); expect(tableFit.columnCount).toBe(13); await page.locator('[data-view="heavenView"]').first().click(); await expect(page.locator(".app-header .overview-strip")).toBeHidden(); await expect(page.locator("#overviewToggle")).toBeHidden(); await page.setViewportSize({ width: 390, height: 844 }); await page.locator('[data-view="limitPool"]').first().click({ force: true }); const pool390 = await page.evaluate(measurePoolTable); expect(pool390.columnCount).toBe(13); expect(pool390.tableLayout).toBe("auto"); expect(pool390.minCellWidth).toBeGreaterThanOrEqual(40); expect(pool390.minNumberWidth).toBeGreaterThanOrEqual(50); expect(pool390.wrapOverflow).toBeGreaterThan(0); expect(pool390.reasonTitle).toContain("板块龙头连板打开空间"); await page.locator("#mobileMarketViewSelect").selectOption("sentimentCycleView"); await page.locator("#overviewToggle").click(); const mobileDetail = await page.locator(".tape-detail").innerText(); for (const label of ["市场情绪", "上涨家数", "下跌家数", "涨停", "跌停", "炸板", "封板率", "两市成交", "数据日期"]) { expect(mobileDetail).toContain(label); } await expect(page.locator("#dataDateMetric")).toBeVisible(); }); test("B-199 screener review and account surfaces fit day night viewports", async ({ page }) => { const fs = require("node:fs"); const path = require("node:path"); const shotDir = path.join(__dirname, "../../runtime/b199-shots"); fs.mkdirSync(shotDir, { recursive: true }); const pageErrors = []; page.on("pageerror", (error) => pageErrors.push(String(error))); const overflowX = () => page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); const shot = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`), fullPage: true }); await mockApplication(page, session("admin", true)); await page.setViewportSize({ width: 1600, height: 1000 }); await page.goto("/index.html"); await page.locator('[data-view="screenerView"]').first().click(); await expect(page.locator("#screenerView")).toHaveClass(/active-view/); expect(await overflowX()).toBeLessThanOrEqual(1); await shot("screener-1600-day"); await page.locator('[data-view="reviewWorkspaceView"]').first().click(); await expect(page.locator("#reviewWorkspaceView")).toHaveClass(/active-view/); expect(await overflowX()).toBeLessThanOrEqual(1); await shot("review-1600-day"); await openHeaderCommandMenu(page); await page.locator("#accountButton").click(); await page.locator('[data-account-panel="membership"]').click(); await expect(page.locator("#settingsDialog")).toBeVisible(); await shot("account-1600-day"); await page.locator("#closeSettingsDialog").click(); await openHeaderCommandMenu(page); await page.locator("#settingsButton").click(); await expect(page.locator("#adminDialog")).toBeVisible(); await shot("admin-1600-day"); await page.locator("#closeAdminDialog").click(); await page.locator("#themeToggle").click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); await page.locator('[data-view="screenerView"]').first().click(); expect(await overflowX()).toBeLessThanOrEqual(1); await shot("screener-1600-night"); await page.locator('[data-view="reviewWorkspaceView"]').first().click(); expect(await overflowX()).toBeLessThanOrEqual(1); await shot("review-1600-night"); await page.setViewportSize({ width: 1280, height: 800 }); await page.locator('[data-view="screenerView"]').first().click(); expect(await overflowX()).toBeLessThanOrEqual(1); await shot("screener-1280-night"); await page.locator('[data-view="reviewWorkspaceView"]').first().click(); expect(await overflowX()).toBeLessThanOrEqual(1); await shot("review-1280-night"); await page.locator("#themeToggle").click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); await page.locator('[data-view="screenerView"]').first().click(); expect(await overflowX()).toBeLessThanOrEqual(1); await shot("screener-1280-day"); await page.locator('[data-view="reviewWorkspaceView"]').first().click(); expect(await overflowX()).toBeLessThanOrEqual(1); await shot("review-1280-day"); await page.locator('[data-view="screenerView"]').first().click(); await page.setViewportSize({ width: 390, height: 844 }); await expect(page.locator("#screenerView")).toHaveClass(/active-view/); expect(await overflowX()).toBeLessThanOrEqual(1); await shot("screener-390-day"); await page.locator(".mobile-primary-tab[data-view=\"reviewWorkspaceView\"]").click(); await expect(page.locator("#reviewWorkspaceView")).toHaveClass(/active-view/); expect(await overflowX()).toBeLessThanOrEqual(1); await shot("review-390-day"); expect(pageErrors).toEqual([]); }); test("desktop header keeps refresh, admin commands and account identity visible", async ({ page }) => { const fs = require("node:fs"); const path = require("node:path"); const shotDir = path.join(__dirname, "../../runtime/b214-shots"); fs.mkdirSync(shotDir, { recursive: true }); const pageErrors = []; page.on("pageerror", (error) => pageErrors.push(String(error))); await mockApplication(page, session("admin", true)); await page.goto("/index.html"); const shot = (name) => page.locator(".app-header").screenshot({ path: path.join(shotDir, `${name}.png`) }); const shotPage = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`) }); const measureHeader = () => page.evaluate(() => { const header = document.querySelector(".app-header"); const tape = document.querySelector(".market-tape"); const actions = document.querySelector(".header-actions"); const refresh = document.querySelector("#refreshButton"); const account = document.querySelector("#accountButton"); const name = document.querySelector("#accountName"); const box = (node) => node ? node.getBoundingClientRect() : null; const visible = (node) => { if (!node || node.hidden) return false; const style = getComputedStyle(node); if (style.display === "none" || style.visibility === "hidden") return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; const tapeBox = box(tape); const actionsBox = box(actions); return { headerOverflow: header ? header.scrollWidth - header.clientWidth : 0, documentOverflow: document.documentElement.scrollWidth - window.innerWidth, tapeHeight: tapeBox ? tapeBox.height : 0, overlap: tapeBox && actionsBox ? Math.max(0, Math.min(tapeBox.right, actionsBox.right) - Math.max(tapeBox.left, actionsBox.left)) * Math.max(0, Math.min(tapeBox.bottom, actionsBox.bottom) - Math.max(tapeBox.top, actionsBox.top)) : 0, refreshVisible: visible(refresh), accountVisible: visible(account), nameVisible: visible(name), nameFits: name ? name.clientWidth >= name.scrollWidth : false, }; }); for (const viewport of [ { width: 1600, height: 1000 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, ]) { await page.setViewportSize(viewport); await expect(page.locator("#headerMenuButton")).toBeHidden(); await expect(page.locator("#headerCommandGroup")).toBeVisible(); await expect(page.locator("#refreshButton")).toBeVisible(); await expect(page.locator("#syncButton")).toBeVisible(); await expect(page.locator("#settingsButton")).toBeVisible(); await expect(page.locator("#accountAdminBadge")).toBeVisible(); await expect(page.locator("#accountVipBadge")).toBeVisible(); await expect(page.locator("#accountButton")).toBeVisible(); await expect(page.locator("#accountName")).toBeVisible(); const geometry = await measureHeader(); expect(geometry.headerOverflow, `${viewport.width} header overflow`).toBeLessThanOrEqual(1); expect(geometry.documentOverflow, `${viewport.width} document overflow`).toBeLessThanOrEqual(1); expect(geometry.overlap, `${viewport.width} tape overlap`).toBe(0); expect(geometry.tapeHeight, `${viewport.width} tape height`).toBeGreaterThan(20); expect(geometry.nameFits, `${viewport.width} account name truncated`).toBe(true); await page.locator("#refreshButton").click(); await expect(page.locator("#loadingOverlay")).toBeHidden(); await page.locator("#settingsButton").click(); await expect(page.locator("#adminDialog")).toBeVisible(); await page.locator("#closeAdminDialog").click(); await page.locator("#accountButton").click(); await expect(page.locator("#accountDropdown")).toBeVisible(); await page.keyboard.press("Escape"); await shot(`${viewport.width}-day`); } await page.locator("#themeToggle").click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); await page.setViewportSize({ width: 1600, height: 1000 }); await shot("1600-night"); await page.locator("#themeToggle").click(); await page.locator('[data-view="heavenView"]').first().click(); await expect(page.locator("#heavenView")).toHaveClass(/active-view/); await expect(page.locator("#refreshButton")).toBeVisible(); await expect(page.locator("#accountButton")).toBeVisible(); await expect(page.locator("#headerMenuButton")).toBeHidden(); await shot("heaven-1600-day"); await page.setViewportSize({ width: 390, height: 844 }); await expect(page.locator("#headerMenuButton")).toBeVisible(); await expect(page.locator("#refreshButton")).toBeHidden(); await page.locator("#headerMenuButton").click(); await expect(page.locator("#headerCommandGroup")).toBeVisible(); await expect(page.locator("#refreshButton")).toBeVisible(); await expect(page.locator("#syncButton")).toBeVisible(); await expect(page.locator("#settingsButton")).toBeVisible(); await expect(page.locator("#accountAdminBadge")).toBeVisible(); await expect(page.locator("#accountVipBadge")).toBeVisible(); await expect(page.locator("#accountButton")).toBeVisible(); await expect(page.locator('[data-mobile-command-target="themeToggle"]')).toBeVisible(); const mobileName = await page.evaluate(measureAccountName); expect(mobileName.fits, "390 account name truncated").toBe(true); expect(mobileName.stacked, "390 identity and account should stack").toBe(true); await shotPage("390-menu-open"); await page.keyboard.press("Escape"); await expect(page.locator("#headerCommandGroup")).toBeHidden(); await mockApplication(page, session("user", false)); await page.setViewportSize({ width: 1600, height: 1000 }); await page.goto("/index.html"); await expect(page.locator("#refreshButton")).toBeVisible(); await expect(page.locator("#syncButton")).toBeHidden(); await expect(page.locator("#settingsButton")).toBeHidden(); await expect(page.locator("#accountAdminBadge")).toBeHidden(); await expect(page.locator("#accountVipBadge")).toBeVisible(); await expect(page.locator("#accountName")).toHaveText("normal_user"); await shot("1600-user-day"); expect(pageErrors).toEqual([]); }); test("header username stays untruncated on desktop and in the mobile menu", async ({ page }) => { test.setTimeout(120_000); const fs = require("node:fs"); const path = require("node:path"); const shotDir = path.join(__dirname, "../../runtime/b217-shots"); fs.mkdirSync(shotDir, { recursive: true }); const measurements = []; const viewports = [ { width: 1600, height: 1000 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 390, height: 844 }, ]; const accounts = [ { label: "admin", username: "review_admin", auth: namedSession("review_admin", "admin", true) }, { label: "user", username: "normal_user", auth: namedSession("normal_user", "user", false) }, ]; for (const account of accounts) { await mockApplication(page, account.auth); 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"]) { await page.setViewportSize({ width: 1600, height: 1000 }); await setColorTheme(page, theme); for (const viewport of viewports) { await page.setViewportSize(viewport); if (viewport.width === 390) { await page.locator("#headerMenuButton").click(); await expect(page.locator("#headerCommandGroup")).toBeVisible(); } else { await expect(page.locator("#headerMenuButton")).toBeHidden(); await expect(page.locator("#accountName")).toBeVisible(); } const geometry = await page.evaluate(measureAccountName); const label = `${account.label}-${viewport.width}-${theme}`; expect(geometry.text, `${label} username`).toBe(account.username); expect(geometry.fits, `${label} accountName truncated ${geometry.clientWidth}<${geometry.scrollWidth}`).toBe(true); expect(geometry.documentOverflow, `${label} document overflow`).toBeLessThanOrEqual(1); if (viewport.width === 390) { expect(geometry.stacked, `${label} identity/account not stacked`).toBe(true); } if (viewport.width >= 1440 && account.label === "admin") { expect(geometry.adminLabelVisible, `${label} admin badge text hidden`).toBe(true); expect(geometry.vipLabelVisible, `${label} vip badge text hidden`).toBe(true); } measurements.push({ account: account.username, theme, width: viewport.width, height: viewport.height, ...geometry, }); const shotName = `${account.label}-${viewport.width}-${theme}.png`; if (viewport.width === 390) { await page.screenshot({ path: path.join(shotDir, shotName) }); await page.keyboard.press("Escape"); await expect(page.locator("#headerCommandGroup")).toBeHidden(); } else { await page.locator(".app-header").screenshot({ path: path.join(shotDir, shotName) }); } } } } fs.writeFileSync(path.join(shotDir, "measurements.json"), `${JSON.stringify(measurements, null, 2)}\n`); }); function measureHeaderProbe() { const name = document.querySelector("#accountName"); const group = document.querySelector("#headerCommandGroup"); const strip = document.querySelector(".app-header .overview-strip"); const stripBox = strip ? strip.getBoundingClientRect() : null; const stripShown = Boolean(stripBox && stripBox.width > 0 && stripBox.height > 0); const tapeClip = stripShown ? [...strip.querySelectorAll("*")].filter((el) => ( el.children.length === 0 && el.scrollWidth > el.clientWidth + 1 )).map((el) => (el.textContent || "").trim()).filter(Boolean) : []; const groupBox = group ? group.getBoundingClientRect() : null; const nameBox = name ? name.getBoundingClientRect() : null; return { groupRight: groupBox ? groupBox.right : 0, nameRight: nameBox ? nameBox.right : 0, nameFits: name ? name.clientWidth >= name.scrollWidth : false, nameText: name ? String(name.textContent || "") : "", tapeClip, documentOverflow: document.documentElement.scrollWidth - window.innerWidth, viewport: window.innerWidth, }; } async function applyColorTheme(page, theme) { const wanted = theme === "night" ? "dark" : "light"; await page.evaluate((value) => { document.documentElement.dataset.theme = value; document.documentElement.style.colorScheme = value; try { localStorage.setItem("xiaobaiTheme", value); } catch (_error) {} }, wanted); await expect(page.locator("html")).toHaveAttribute("data-theme", wanted); } async function probeHeaderWorkspaces(page, { account, theme, viewport, views, measurements }) { await page.setViewportSize(viewport); await applyColorTheme(page, theme); for (const view of views) { await page.locator(`[data-view="${view}"]`).first().click(); await expect(page.locator(`#${view}`)).toHaveClass(/active-view/); const geometry = await page.evaluate(measureHeaderProbe); const label = `${account.label}-${view}-${viewport.width}-${theme}`; expect(geometry.nameText, `${label} username`).toBe(account.username); expect(geometry.nameFits, `${label} accountName truncated`).toBe(true); expect(geometry.groupRight, `${label} command group overflow ${geometry.groupRight}>${geometry.viewport}`).toBeLessThanOrEqual(geometry.viewport + 0.5); expect(geometry.nameRight, `${label} accountName overflow ${geometry.nameRight}>${geometry.viewport}`).toBeLessThanOrEqual(geometry.viewport + 0.5); expect(geometry.documentOverflow, `${label} document overflow`).toBeLessThanOrEqual(1); expect(geometry.tapeClip, `${label} tape clipped ${geometry.tapeClip.join("|")}`).toEqual([]); measurements.push({ account: account.username, view, theme, width: viewport.width, ...geometry }); } } test("desktop header keeps commands in view and tape text unclipped across workspaces", async ({ page }) => { test.setTimeout(120_000); const fs = require("node:fs"); const path = require("node:path"); const shotDir = path.join(__dirname, "../../runtime/b221-shots"); fs.mkdirSync(shotDir, { recursive: true }); const measurements = []; const views = [ "sentimentCycleView", "auctionView", "themeLibraryView", "popularityView", "rotationView", "mentorView", "screenerView", "reviewWorkspaceView", "heavenView", ]; const viewports = [ { width: 1600, height: 1000 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, ]; const accounts = [ { label: "admin", username: "review_admin", auth: namedSession("review_admin", "admin", true) }, { label: "user", username: "normal_user", auth: namedSession("normal_user", "user", false) }, ]; for (const account of accounts) { await mockApplication(page, account.auth); await page.goto("/index.html"); await expect(page.locator("#accountName")).toHaveText(account.username); for (const viewport of viewports) { await probeHeaderWorkspaces(page, { account, theme: "day", viewport, views, measurements }); await page.locator('[data-view="themeLibraryView"]').first().click(); await page.locator(".app-header").screenshot({ path: path.join(shotDir, `${account.label}-${viewport.width}-day.png`), }); } await probeHeaderWorkspaces(page, { account, theme: "night", viewport: { width: 1600, height: 1000 }, views, measurements, }); await page.locator('[data-view="sentimentCycleView"]').first().click(); await page.locator(".app-header").screenshot({ path: path.join(shotDir, `${account.label}-1600-night.png`), }); } await mockApplication(page, accounts[0].auth); await page.goto("/index.html"); await page.setViewportSize({ width: 390, height: 844 }); await applyColorTheme(page, "day"); await page.locator("#headerMenuButton").click(); await expect(page.locator("#headerCommandGroup")).toBeVisible(); const mobileDay = await page.evaluate(measureAccountName); expect(mobileDay.fits, "390 day account name truncated").toBe(true); expect(mobileDay.stacked, "390 day identity and account should stack").toBe(true); await page.screenshot({ path: path.join(shotDir, "admin-390-day.png") }); await page.keyboard.press("Escape"); await applyColorTheme(page, "night"); await page.locator("#headerMenuButton").click(); const mobileNight = await page.evaluate(measureAccountName); expect(mobileNight.fits, "390 night account name truncated").toBe(true); expect(mobileNight.stacked, "390 night identity and account should stack").toBe(true); await page.screenshot({ path: path.join(shotDir, "admin-390-night.png") }); fs.writeFileSync(path.join(shotDir, "measurements.json"), `${JSON.stringify(measurements, null, 2)}\n`); });