898 lines
46 KiB
JavaScript
898 lines
46 KiB
JavaScript
const { test, expect } = require("@playwright/test");
|
|
|
|
const dashboard = {
|
|
meta: {
|
|
trade_date: "2026-07-22",
|
|
requested_date: "2026-07-22",
|
|
source: "tushare",
|
|
realtime: false,
|
|
cached: true,
|
|
market_status: "closed",
|
|
updated_at: "2026-07-22T15:00:00+08:00",
|
|
},
|
|
overview: {
|
|
up_count: 2100,
|
|
down_count: 2800,
|
|
limit_up_count: 42,
|
|
limit_down_count: 8,
|
|
broken_count: 17,
|
|
seal_rate: 71.2,
|
|
amount_billion: 12600,
|
|
sentiment_score: 48,
|
|
},
|
|
limits: [],
|
|
broken: [],
|
|
down_limits: [],
|
|
yesterday_limits: [],
|
|
limit_performance: [],
|
|
ladders: [],
|
|
sectors: [],
|
|
sector_rotation: [],
|
|
};
|
|
|
|
function session(role = "admin", subscribed = true) {
|
|
return {
|
|
authenticated: true,
|
|
csrf_token: "test-csrf",
|
|
user: {
|
|
id: role === "admin" ? 1 : 2,
|
|
username: role === "admin" ? "admin_user" : "normal_user",
|
|
role,
|
|
membership: {
|
|
active: role === "admin" || subscribed,
|
|
subscribed,
|
|
is_admin: role === "admin",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function mentorDirectory(role = "admin") {
|
|
const mentors = [
|
|
{
|
|
id: "source-a",
|
|
name: "原帖老师",
|
|
description: "依据长期实盘原帖提炼",
|
|
tagline: "先看周期,再看机会。",
|
|
focus: ["情绪周期", "仓位纪律"],
|
|
evidence: { grade: "A", label: "实盘原帖", note: "长期原始实盘记录" },
|
|
quality: { score: 6, total: 6, status: "pass" },
|
|
private: false,
|
|
},
|
|
{
|
|
id: "source-b",
|
|
name: "多源老师",
|
|
description: "依据公开访谈与多源资料整理",
|
|
tagline: "确认之后再行动。",
|
|
focus: ["主线确认", "风险管理"],
|
|
evidence: { grade: "B", label: "多源整理", note: "公开访谈与多源材料" },
|
|
quality: { score: 6, total: 6, status: "pass" },
|
|
private: false,
|
|
},
|
|
{
|
|
id: "source-c",
|
|
name: "推演老师",
|
|
description: "公开语录较少,以行为推演为主",
|
|
tagline: "只讨论可验证的行为。",
|
|
focus: ["行为推演", "诚实边界"],
|
|
evidence: { grade: "C", label: "行为推演", note: "公开语录较少" },
|
|
quality: { score: 5, total: 6, status: "conditional" },
|
|
private: false,
|
|
},
|
|
];
|
|
for (let index = 1; index <= 18; index += 1) {
|
|
const grade = ["A", "B", "C"][(index - 1) % 3];
|
|
mentors.push({
|
|
id: `extra-${index}`,
|
|
name: `扩展模型${String(index).padStart(2, "0")}`,
|
|
description: `用于验证完整目录密度的${grade}级思维模型`,
|
|
tagline: "保持证据边界。",
|
|
focus: ["市场结构", "条件预案"],
|
|
evidence: { grade, label: { A: "原始语料", B: "多源整理", C: "行为材料" }[grade], note: `${grade}级测试素材` },
|
|
quality: { score: 6, total: 6, status: "pass" },
|
|
private: false,
|
|
});
|
|
}
|
|
if (role === "admin") mentors.unshift({
|
|
id: "private-owner",
|
|
name: "私有老师",
|
|
description: "依据个人复盘记录提炼",
|
|
tagline: "只对自己开放。",
|
|
focus: ["个人复盘", "交易纪律"],
|
|
evidence: { grade: "A", label: "私有原始语料", note: "仅限管理员本人使用" },
|
|
quality: { score: null, total: null, status: "private" },
|
|
private: true,
|
|
});
|
|
return mentors;
|
|
}
|
|
|
|
async function mockApplication(page, authSession = session()) {
|
|
await page.route("**/api/**", async (route) => {
|
|
const url = new URL(route.request().url());
|
|
let payload = { ok: true };
|
|
if (url.pathname === "/api/auth/me") payload = authSession;
|
|
else if (url.pathname === "/api/dashboard") payload = dashboard;
|
|
else if (url.pathname === "/api/stock/002141/preview") {
|
|
payload = {
|
|
meta: { trade_date: "2026-07-23", 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") {
|
|
payload = {
|
|
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" || url.pathname === "/api/notes") payload = { items: [] };
|
|
else if (url.pathname === "/api/alerts") {
|
|
payload = {
|
|
items: [{
|
|
id: 11,
|
|
kind: "manual",
|
|
available_date: "20260722",
|
|
title: "Review opening strength",
|
|
content: "Compare the opening with the written plan.",
|
|
code: "002141",
|
|
is_read: false,
|
|
due: true,
|
|
}],
|
|
unread_count: 1,
|
|
};
|
|
} else if (url.pathname === "/api/trades") {
|
|
payload = {
|
|
items: [{
|
|
id: 7,
|
|
trade_date: "20260722",
|
|
code: "002141",
|
|
name: "Test Stock",
|
|
action: "buy",
|
|
action_label: "Buy",
|
|
price: 10.2,
|
|
quantity: 1000,
|
|
position_pct: 20,
|
|
pnl_amount: null,
|
|
pnl_pct: null,
|
|
emotion: "calm",
|
|
emotion_label: "Calm",
|
|
tags: ["planned"],
|
|
thesis: "Strength confirmed after the open.",
|
|
execution: "Executed within the planned range.",
|
|
}],
|
|
summary: { total: 1, realized: 0, win_rate: null, pnl_amount: null, average_position: 20 },
|
|
};
|
|
} else if (url.pathname === "/api/assistant/messages") {
|
|
payload = {
|
|
items: [{ role: "assistant", content: "Review evidence before forming a conclusion.", context_date: "20260722" }],
|
|
};
|
|
} else if (url.pathname === "/api/search") payload = { groups: { stocks: [], sectors: [], themes: [], indices: [] } };
|
|
else if (url.pathname === "/api/dragon-tiger") {
|
|
payload = {
|
|
meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "empty", source: "tushare" },
|
|
summary: {},
|
|
traders: [],
|
|
unclassified_seats: [],
|
|
};
|
|
} else if (url.pathname === "/api/sentiment/history") payload = { rows: [], components: [] };
|
|
else if (url.pathname === "/api/rotation/history") payload = { days: [], rows: [], sectors: [] };
|
|
else if (url.pathname === "/api/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") {
|
|
payload = {
|
|
trade_date: "20260722",
|
|
regime: { id: "repair", label: "修复", confidence: 70, reason: "测试", evidence: [] },
|
|
regimes: [{ id: "repair", label: "修复" }],
|
|
factor_data: { ready: false, date_count: 0 },
|
|
llm: { configured: true },
|
|
strategies: [],
|
|
};
|
|
} else if (url.pathname === "/api/screener/tracking") {
|
|
payload = {
|
|
batches: [{
|
|
selection_date: "20260721",
|
|
strategy_name: "Repair confirmation",
|
|
items: [{
|
|
code: "002141",
|
|
name: "Test Stock",
|
|
entry_price: 10,
|
|
t1_open: 1.2,
|
|
t1_close: 2.1,
|
|
t3_close: null,
|
|
t5_close: null,
|
|
max_gain: 3.4,
|
|
max_drawdown: -1.1,
|
|
observed_days: 1,
|
|
status: "tracking",
|
|
}],
|
|
}],
|
|
summary: { total: 1, observed: 1, t1_win_rate: 100, t5_win_rate: null, average_t5: null },
|
|
};
|
|
} else if (url.pathname === "/api/heaven/readings") {
|
|
payload = {
|
|
mode: url.searchParams.get("mode") || "fortune",
|
|
items: [{
|
|
id: 31,
|
|
mode: "fortune",
|
|
context_date: "20260723",
|
|
subject: "2026-07-23 观气",
|
|
subject_detail: "丙午年 · 乙未月 · 己丑日 · 土气偏显",
|
|
answer: "三层气机已经合参,今日宜先定节奏,再看行动。",
|
|
created_at: "2026-07-23T09:12:00+08:00",
|
|
}],
|
|
};
|
|
} else if (url.pathname === "/api/mentors/setup") {
|
|
payload = { trade_date: "20260722", mentors: mentorDirectory(authSession.user.role) };
|
|
} else if (url.pathname === "/api/mentors/chat") {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/x-ndjson; charset=utf-8",
|
|
body: [
|
|
JSON.stringify({ type: "delta", content: "## 判断\n先看市场结构。\n\n" }),
|
|
JSON.stringify({ type: "delta", content: "- 等待确认\n- 控制仓位" }),
|
|
JSON.stringify({ type: "meta", data_trade_date: "20260722", notice: "" }),
|
|
JSON.stringify({ type: "done" }),
|
|
].join("\n"),
|
|
});
|
|
return;
|
|
}
|
|
else if (url.pathname === "/api/heaven/setup") {
|
|
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "测试环境不加载问天数据" }) });
|
|
return;
|
|
}
|
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(payload) });
|
|
});
|
|
}
|
|
|
|
test("admin shell opens every primary workspace and global search", async ({ page }) => {
|
|
await mockApplication(page);
|
|
await page.goto("/index.html");
|
|
await expect(page.locator("#authGate")).toBeHidden();
|
|
await expect(page.locator("#settingsButton")).toBeVisible();
|
|
await expect(page.locator("#syncButton")).toBeVisible();
|
|
await page.locator("#alertButton").click();
|
|
await expect(page.locator("#alertsDialog")).toBeVisible();
|
|
await page.locator("#closeAlertsDialog").click();
|
|
await page.locator("#assistantButton").click();
|
|
await expect(page.locator("#assistantDialog")).toBeVisible();
|
|
await page.locator("#closeAssistantDialog").click();
|
|
|
|
const views = [
|
|
"auctionView", "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView",
|
|
"performanceView", "ladderView", "rotationView", "themeLibraryView", "popularityView", "dragonView", "screenerView",
|
|
"mentorView", "heavenView", "reviewWorkspaceView",
|
|
];
|
|
for (const view of views) {
|
|
await page.locator(`[data-view="${view}"]`).first().click();
|
|
await expect(page.locator(`#${view}`)).toHaveClass(/active-view/);
|
|
}
|
|
|
|
await page.keyboard.press("Control+K");
|
|
await expect(page.locator("#globalSearchDialog")).toBeVisible();
|
|
await expect(page.locator("#globalSearchInput")).toBeFocused();
|
|
});
|
|
|
|
test("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("#auctionAboveCount")).toHaveText("1");
|
|
await expect(page.locator("#auctionThemeCarry")).toContainText("强承接");
|
|
await expect(page.locator("#auctionAmountValue")).toHaveText("2.50 亿");
|
|
await expect(page.locator("#auctionNewsTitle")).toHaveText("隔夜消息反馈");
|
|
await expect(page.locator(".auction-news-entry")).toContainText("暂不可用");
|
|
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("#auctionExpectationFilterbar")).toBeHidden();
|
|
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("#popularityTableBody")).toContainText("双榜共识");
|
|
await page.locator("#popularityTableBody tr").click();
|
|
await expect(page.locator("#stockDialog")).toBeVisible();
|
|
});
|
|
|
|
test("regular account cannot see admin controls and member features are gated", async ({ page }) => {
|
|
await mockApplication(page, session("user", false));
|
|
await page.goto("/index.html");
|
|
await expect(page.locator("#settingsButton")).toBeHidden();
|
|
await expect(page.locator("#syncButton")).toBeHidden();
|
|
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
|
await page.locator('[data-view="screenerView"]').first().click();
|
|
await expect(page.locator("#screenerView .member-gate")).toBeVisible();
|
|
await expect(page.locator("#screenerRunButton")).toBeDisabled();
|
|
await page.locator("#assistantButton").click();
|
|
await expect(page.locator("#settingsDialog")).toBeHidden();
|
|
await expect(page.locator("#assistantDialog")).toBeVisible();
|
|
await expect(page.locator("#assistantMemberGate")).toContainText("复盘助手仅对会员开放");
|
|
await expect(page.locator("#assistantMemberGate")).toBeVisible();
|
|
await expect(page.locator("#assistantMemberContent")).toHaveAttribute("aria-disabled", "true");
|
|
await expect(page.locator("#assistantQuestion")).toBeDisabled();
|
|
await expect(page.locator("[data-assistant-prompt]").first()).toBeDisabled();
|
|
await expect(page.locator("#sendAssistant")).toBeDisabled();
|
|
});
|
|
|
|
test("stock hover preview ignores the selected historical date", async ({ page }) => {
|
|
await mockApplication(page, session("user", true));
|
|
await page.goto("/index.html");
|
|
await page.locator("#tradeDate").fill("2026-07-01");
|
|
const requestPromise = page.waitForRequest((request) => request.url().includes("/api/stock/002141/preview"));
|
|
await page.evaluate(() => showStockPreview("002141", document.querySelector("#globalSearchButton")));
|
|
const request = await requestPromise;
|
|
const requestUrl = new URL(request.url());
|
|
expect(requestUrl.searchParams.has("trade_date")).toBe(false);
|
|
await expect(page.locator("#stockPreviewDate")).toHaveText("2026-07-23");
|
|
await expect(page.locator("#stockPreviewName")).toHaveText("Test Stock");
|
|
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("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 = CHART_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.evaluate(() => startHeartBreathing());
|
|
|
|
const timing = await page.evaluate(() => ({
|
|
remaining: state.heartBreathingEndsAt - Date.now(),
|
|
incenseDuration: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration,
|
|
incenseDelay: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDelay,
|
|
rippleDuration: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDuration,
|
|
rippleDelay: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDelay,
|
|
rippleAnimation: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationName,
|
|
rippleKeyframes: document.querySelector(".heart-breath-ripple span").getAnimations()[0].effect.getKeyframes(),
|
|
}));
|
|
expect(timing.remaining).toBeGreaterThan(45_000);
|
|
expect(timing.remaining).toBeLessThanOrEqual(46_000);
|
|
expect(timing.incenseDuration).toBe("45s");
|
|
expect(timing.incenseDelay).toBe("1s");
|
|
expect(timing.rippleDuration).toBe("9s");
|
|
expect(timing.rippleDelay).toBe("1s");
|
|
expect(timing.rippleAnimation).toContain("heart-breath-ripple");
|
|
expect(timing.rippleKeyframes.at(-1).transform).toContain("0.62");
|
|
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "prepare");
|
|
await expect(page.locator("#breathingPhase")).toHaveText("静");
|
|
await expect(page.locator("#breathingSeconds, #breathingProgress, .breathing-orbit")).toHaveCount(0);
|
|
|
|
await page.evaluate(() => {
|
|
clearInterval(state.heartTimer);
|
|
state.heartTimer = null;
|
|
state.heartSeconds = 45;
|
|
state.heartBreathingEndsAt = Date.now() + 44_500;
|
|
updateBreathingDisplay();
|
|
});
|
|
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "inhale");
|
|
await expect(page.locator("#breathingPhase")).toHaveText("吸");
|
|
|
|
await page.evaluate(() => {
|
|
state.heartSeconds = 42;
|
|
state.heartBreathingEndsAt = Date.now() + 41_500;
|
|
updateBreathingDisplay();
|
|
});
|
|
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "hold");
|
|
await expect(page.locator("#breathingPhase")).toHaveText("顿");
|
|
|
|
await page.evaluate(() => {
|
|
state.heartSeconds = 39;
|
|
state.heartBreathingEndsAt = Date.now() + 38_500;
|
|
updateBreathingDisplay();
|
|
});
|
|
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "exhale");
|
|
await expect(page.locator("#breathingPhase")).toHaveText("呼");
|
|
});
|
|
|
|
test("mobile shell stays within the viewport", async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await mockApplication(page, session("user", true));
|
|
await page.goto("/index.html");
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
|
expect(overflow).toBeLessThanOrEqual(1);
|
|
await expect(page.locator("#globalSearchButton")).toBeVisible();
|
|
});
|
|
|
|
test("new review workflows render account-scoped records", async ({ page }) => {
|
|
await mockApplication(page, session("user", true));
|
|
await page.goto("/index.html");
|
|
|
|
await page.locator('[data-view="screenerView"]').first().click();
|
|
await expect(page.locator("#trackingTableBody tr")).toHaveCount(1);
|
|
await expect(page.locator("#trackingEmpty")).toBeHidden();
|
|
|
|
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
|
|
await expect(page.locator("#tradeLogTableBody tr")).toHaveCount(1);
|
|
await expect(page.locator("#tradeLogEmpty")).toBeHidden();
|
|
await expect(page.locator("#tradeLogForm")).toBeHidden();
|
|
await page.locator("#openTradeLogDialog").click();
|
|
await expect(page.locator("#tradeLogDialog")).toBeVisible();
|
|
await page.locator("#tradeLogCode").fill("002141");
|
|
await page.locator("#tradeLogName").fill("Test Stock");
|
|
await page.locator("#tradeLogPrice").fill("10.8");
|
|
await page.locator("#saveTradeLog").click();
|
|
await expect(page.locator("#tradeLogDialog")).toBeHidden();
|
|
await expect(page.locator("#reviewWorkspaceView")).toHaveClass(/active-view/);
|
|
await expect(page.locator("#tradeLogTableBody tr")).toHaveCount(1);
|
|
await page.locator('[data-trade-action="edit"]').click();
|
|
await expect(page.locator("#tradeLogDialogTitle")).toHaveText("编辑交易日志");
|
|
await page.locator("#cancelTradeEdit").click();
|
|
await expect(page.locator("#tradeLogDialog")).toBeHidden();
|
|
|
|
await page.locator("#alertButton").click();
|
|
await expect(page.locator("#alertList .alert-item")).toHaveCount(1);
|
|
await expect(page.locator("#alertBadge")).toHaveText("1");
|
|
await page.locator("#closeAlertsDialog").click();
|
|
|
|
await page.locator("#assistantButton").click();
|
|
await expect(page.locator("#assistantMemberGate")).toBeHidden();
|
|
await expect(page.locator("#assistantMemberContent")).toHaveAttribute("aria-disabled", "false");
|
|
await expect(page.locator("#assistantMessages .assistant-message")).toHaveCount(1);
|
|
await expect(page.locator("#assistantQuestion")).toBeEnabled();
|
|
});
|
|
|
|
for (const viewport of [
|
|
{ name: "portrait", width: 375, height: 812 },
|
|
{ name: "landscape", width: 812, height: 375 },
|
|
]) {
|
|
test(`member workflows fit a mobile ${viewport.name} viewport`, async ({ page }) => {
|
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
await mockApplication(page, session("user", true));
|
|
await page.goto("/index.html");
|
|
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
|
expect(overflow).toBeLessThanOrEqual(1);
|
|
await expect(page.locator("#openTradeLogDialog")).toBeVisible();
|
|
await expect(page.locator("#tradeLogForm")).toBeHidden();
|
|
await page.locator("#openTradeLogDialog").click();
|
|
await expect(page.locator("#tradeLogDialog")).toBeVisible();
|
|
await expect(page.locator("#tradeLogForm")).toBeVisible();
|
|
const dialogWidth = await page.locator("#tradeLogDialog").evaluate((dialog) => dialog.getBoundingClientRect().width);
|
|
expect(dialogWidth).toBeLessThanOrEqual(viewport.width);
|
|
await page.locator("#closeTradeLogDialog").click();
|
|
await page.evaluate(() => {
|
|
state.heavenInterpretations.fortune = {
|
|
id: 31,
|
|
subject: "2026-07-23 观气",
|
|
subject_detail: "丙午年 · 乙未月 · 己丑日",
|
|
answer: "当日解运结果",
|
|
context_date: "20260723",
|
|
created_at: "2026-07-23T09:12:00+08:00",
|
|
};
|
|
openHeavenReading("fortune", { loading: false });
|
|
});
|
|
await expect(page.locator("#heavenReadingDialog")).toBeVisible();
|
|
const readingWidth = await page.locator("#heavenReadingDialog").evaluate((dialog) => dialog.getBoundingClientRect().width);
|
|
expect(readingWidth).toBeLessThanOrEqual(viewport.width);
|
|
await page.locator("#closeHeavenReadingDialog").click();
|
|
});
|
|
}
|
|
|
|
test("reduced-motion preference suppresses continuous animation", async ({ page }) => {
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await mockApplication(page, session("user", true));
|
|
await page.goto("/index.html");
|
|
expect(await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true);
|
|
const motion = await page.locator(".sentiment-gauge").evaluate((element) => {
|
|
const style = getComputedStyle(element, "::after");
|
|
return { duration: style.animationDuration, iterations: style.animationIterationCount };
|
|
});
|
|
expect(Number.parseFloat(motion.duration)).toBeLessThanOrEqual(0.00001);
|
|
expect(motion.iterations).toBe("1");
|
|
});
|
|
|
|
test("heaven workspace actions remain compact and do not overlap", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await mockApplication(page, session("user", true));
|
|
await page.goto("/index.html");
|
|
await page.locator('[data-view="heavenView"]').first().click();
|
|
|
|
const titleSize = Number.parseFloat(await page.locator("#heavenView .heaven-toolbar h2").evaluate((element) => getComputedStyle(element).fontSize));
|
|
expect(titleSize).toBeLessThanOrEqual(22);
|
|
|
|
const trendButtons = page.locator(".heaven-trend-actions .button");
|
|
await expect(trendButtons).toHaveCount(3);
|
|
for (let index = 0; index < await trendButtons.count(); index += 1) {
|
|
const box = await trendButtons.nth(index).boundingBox();
|
|
expect(box.width).toBeLessThanOrEqual(130);
|
|
expect(box.height).toBeLessThanOrEqual(44);
|
|
}
|
|
await page.locator('[data-heaven-panel="fortune"]').click();
|
|
const fortuneActions = await page.locator("#heavenFortunePanel .fortune-heading-actions").boundingBox();
|
|
const fortunePanel = await page.locator("#heavenFortunePanel").boundingBox();
|
|
expect(fortuneActions.x + fortuneActions.width).toBeLessThanOrEqual(fortunePanel.x + fortunePanel.width + 1);
|
|
await page.evaluate(() => {
|
|
state.heavenInterpretations.fortune = {
|
|
id: 31,
|
|
mode: "fortune",
|
|
context_date: "20260723",
|
|
subject: "2026-07-23 观气",
|
|
subject_detail: "丙午年 · 乙未月 · 己丑日",
|
|
answer: "三层气机已经合参,今日宜先定节奏,再看行动。",
|
|
created_at: "2026-07-23T09:12:00+08:00",
|
|
};
|
|
openHeavenReading("fortune", { loading: false });
|
|
});
|
|
const readingDialog = await page.locator("#heavenReadingDialog").boundingBox();
|
|
expect(readingDialog.width).toBeLessThanOrEqual(920);
|
|
expect(readingDialog.height).toBeLessThanOrEqual(820);
|
|
const readingHeader = await page.locator("#heavenReadingDialog .dialog-header").boundingBox();
|
|
const readingTabs = await page.locator("#heavenReadingDialog .heaven-reading-tabs").boundingBox();
|
|
expect(readingHeader.y + readingHeader.height).toBeLessThanOrEqual(readingTabs.y + 1);
|
|
await page.locator("#closeHeavenReadingDialog").click();
|
|
|
|
await page.locator('[data-heaven-panel="heart"]').click();
|
|
const heartControls = await page.locator(".heart-toolbar-controls").boundingBox();
|
|
const heartPanel = await page.locator("#heavenHeartPanel").boundingBox();
|
|
expect(heartControls.width).toBeLessThanOrEqual(190);
|
|
expect(heartControls.x + heartControls.width).toBeLessThanOrEqual(heartPanel.x + heartPanel.width + 1);
|
|
const historyBox = await page.locator("#historyHeartButton").boundingBox();
|
|
const soundBox = await page.locator("#heartSoundToggle").boundingBox();
|
|
expect(historyBox.width).toBeLessThanOrEqual(100);
|
|
expect(historyBox.x + historyBox.width).toBeLessThanOrEqual(soundBox.x);
|
|
|
|
});
|
|
|
|
test("heaven workspace controls fit a narrow viewport", async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await mockApplication(page, session("user", true));
|
|
await page.goto("/index.html");
|
|
await page.locator('[data-view="heavenView"]').first().click();
|
|
expect(await page.evaluate(() => window.scrollY)).toBe(0);
|
|
|
|
const trendActions = await page.locator(".heaven-trend-actions").boundingBox();
|
|
expect(trendActions.width).toBeLessThanOrEqual(347);
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
|
|
await page.locator('[data-heaven-panel="fortune"]').click();
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
|
|
const fortuneButtons = page.locator("#heavenFortunePanel .fortune-heading-actions .button");
|
|
const firstFortuneButton = await fortuneButtons.first().boundingBox();
|
|
const secondFortuneButton = await fortuneButtons.last().boundingBox();
|
|
expect(Math.abs(firstFortuneButton.width - secondFortuneButton.width)).toBeLessThanOrEqual(1);
|
|
expect(Math.abs(firstFortuneButton.y - secondFortuneButton.y)).toBeLessThanOrEqual(1);
|
|
await page.locator('[data-heaven-panel="heart"]').click();
|
|
const heartControls = await page.locator(".heart-toolbar-controls").boundingBox();
|
|
expect(heartControls.width).toBeLessThanOrEqual(80);
|
|
expect(heartControls.x + heartControls.width).toBeLessThanOrEqual(375);
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test("mentor directory exposes evidence filters and private owner metadata", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await mockApplication(page, session("admin", true));
|
|
await page.goto("/index.html");
|
|
await page.locator('[data-view="mentorView"]').first().click();
|
|
|
|
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22);
|
|
await expect(page.locator('#mentorList [data-mentor-id="private-owner"] .mentor-badge.private')).toContainText("仅自己");
|
|
await expect(page.locator('#mentorList [data-mentor-id="source-a"] .mentor-badge.grade-a')).toHaveText("A");
|
|
await expect(page.locator('#mentorList [data-mentor-id="source-c"] .mentor-badge.quality')).toHaveText("5/6");
|
|
|
|
await page.locator("#mentorSearchInput").fill("行为推演");
|
|
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(1);
|
|
await expect(page.locator("#mentorCount")).toHaveText("1 / 22 位");
|
|
await page.locator("#mentorSearchInput").fill("");
|
|
await page.locator('[data-mentor-grade="B"]').click();
|
|
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(7);
|
|
await page.locator('#mentorList [data-mentor-id="source-b"]').click();
|
|
await expect(page.locator("#activeMentorName")).toHaveText("多源老师");
|
|
await expect(page.locator("#activeMentorBadges")).toContainText("B · 多源整理");
|
|
await expect(page.locator("#activeMentorEvidence")).toHaveText("公开访谈与多源材料");
|
|
});
|
|
|
|
test("mentor pins, custom order and streamed replies work together", async ({ page }) => {
|
|
await mockApplication(page, session("admin", true));
|
|
await page.goto("/index.html");
|
|
await page.locator('[data-view="mentorView"]').first().click();
|
|
|
|
await page.locator('[data-mentor-pin="source-c"]').click();
|
|
await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-c");
|
|
await page.locator('[data-mentor-pin="source-b"]').click();
|
|
await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-b");
|
|
|
|
await page.locator("#mentorSortToggle").click();
|
|
await page.locator('[data-mentor-target="source-b"][data-mentor-move="down"]').click();
|
|
await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-c");
|
|
|
|
await page.locator('[data-mentor-id="source-c"]').click();
|
|
await page.locator("#mentorQuestion").fill("现在怎么看?");
|
|
await page.locator("#sendMentorQuestion").click();
|
|
const answer = page.locator("#mentorMessages .mentor-message.assistant").last();
|
|
await expect(answer).toContainText("先看市场结构。");
|
|
await expect(answer.locator(".mentor-answer-list li")).toHaveCount(2);
|
|
await expect(answer.locator("br")).toHaveCount(0);
|
|
await expect(page.locator("#mentorMessages .assistant-stream-caret")).toHaveCount(0);
|
|
});
|
|
|
|
test("mobile mentor directory opens as a searchable selector and hides private mentors", async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await mockApplication(page, session("user", true));
|
|
await page.goto("/index.html");
|
|
await page.locator('[data-view="mentorView"]').first().click();
|
|
|
|
await expect(page.locator("#mentorDirectoryToggle")).toBeVisible();
|
|
await page.locator("#mentorDirectoryToggle").click();
|
|
await expect(page.locator("#mentorView .mentor-sidebar")).toHaveClass(/is-open/);
|
|
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(21);
|
|
await expect(page.locator('#mentorList [data-mentor-id="private-owner"]')).toHaveCount(0);
|
|
await page.locator('#mentorList [data-mentor-id="source-c"]').click();
|
|
await expect(page.locator("#mentorView .mentor-sidebar")).not.toHaveClass(/is-open/);
|
|
await expect(page.locator("#mobileActiveMentorName")).toHaveText("推演老师");
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
|
|
});
|