277 lines
11 KiB
JavaScript
277 lines
11 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",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function mockApplication(page, authSession = session()) {
|
|
await page.route("**/api/**", async (route) => {
|
|
const url = new URL(route.request().url());
|
|
let payload = { ok: true };
|
|
if (url.pathname === "/api/auth/me") payload = authSession;
|
|
else if (url.pathname === "/api/dashboard") payload = dashboard;
|
|
else if (url.pathname === "/api/stock/002141/preview") {
|
|
payload = {
|
|
meta: { trade_date: "2026-07-23", realtime: false, intraday_notice: "" },
|
|
stock: { code: "002141", name: "Test Stock", industry: "Test Sector", price: 10.8, change: 2.4 },
|
|
prices: [
|
|
{ trade_date: "2026-07-22", open: 10, high: 10.5, low: 9.9, close: 10.2, volume: 1000 },
|
|
{ trade_date: "2026-07-23", open: 10.3, high: 10.9, low: 10.2, close: 10.8, volume: 1200 },
|
|
],
|
|
intraday: [],
|
|
};
|
|
} else if (url.pathname === "/api/watchlist" || url.pathname === "/api/notes") payload = { items: [] };
|
|
else if (url.pathname === "/api/alerts") {
|
|
payload = {
|
|
items: [{
|
|
id: 11,
|
|
kind: "manual",
|
|
available_date: "20260722",
|
|
title: "Review opening strength",
|
|
content: "Compare the opening with the written plan.",
|
|
code: "002141",
|
|
is_read: false,
|
|
due: true,
|
|
}],
|
|
unread_count: 1,
|
|
};
|
|
} else if (url.pathname === "/api/trades") {
|
|
payload = {
|
|
items: [{
|
|
id: 7,
|
|
trade_date: "20260722",
|
|
code: "002141",
|
|
name: "Test Stock",
|
|
action: "buy",
|
|
action_label: "Buy",
|
|
price: 10.2,
|
|
quantity: 1000,
|
|
position_pct: 20,
|
|
pnl_amount: null,
|
|
pnl_pct: null,
|
|
emotion: "calm",
|
|
emotion_label: "Calm",
|
|
tags: ["planned"],
|
|
thesis: "Strength confirmed after the open.",
|
|
execution: "Executed within the planned range.",
|
|
}],
|
|
summary: { total: 1, realized: 0, win_rate: null, pnl_amount: null, average_position: 20 },
|
|
};
|
|
} else if (url.pathname === "/api/assistant/messages") {
|
|
payload = {
|
|
items: [{ role: "assistant", content: "Review evidence before forming a conclusion.", context_date: "20260722" }],
|
|
};
|
|
} else if (url.pathname === "/api/search") payload = { groups: { stocks: [], sectors: [], themes: [], indices: [] } };
|
|
else if (url.pathname === "/api/dragon-tiger") {
|
|
payload = {
|
|
meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "empty", source: "tushare" },
|
|
summary: {},
|
|
traders: [],
|
|
unclassified_seats: [],
|
|
};
|
|
} else if (url.pathname === "/api/sentiment/history") payload = { rows: [], components: [] };
|
|
else if (url.pathname === "/api/rotation/history") payload = { days: [], rows: [], sectors: [] };
|
|
else if (url.pathname === "/api/screener/setup") {
|
|
payload = {
|
|
trade_date: "20260722",
|
|
regime: { id: "repair", label: "修复", confidence: 70, reason: "测试", evidence: [] },
|
|
regimes: [{ id: "repair", label: "修复" }],
|
|
factor_data: { ready: false, date_count: 0 },
|
|
llm: { configured: true },
|
|
strategies: [],
|
|
};
|
|
} else if (url.pathname === "/api/screener/tracking") {
|
|
payload = {
|
|
batches: [{
|
|
selection_date: "20260721",
|
|
strategy_name: "Repair confirmation",
|
|
items: [{
|
|
code: "002141",
|
|
name: "Test Stock",
|
|
entry_price: 10,
|
|
t1_open: 1.2,
|
|
t1_close: 2.1,
|
|
t3_close: null,
|
|
t5_close: null,
|
|
max_gain: 3.4,
|
|
max_drawdown: -1.1,
|
|
observed_days: 1,
|
|
status: "tracking",
|
|
}],
|
|
}],
|
|
summary: { total: 1, observed: 1, t1_win_rate: 100, t5_win_rate: null, average_t5: null },
|
|
};
|
|
} else if (url.pathname === "/api/mentors/setup") payload = { trade_date: "20260722", mentors: [] };
|
|
else if (url.pathname === "/api/heaven/setup") {
|
|
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "测试环境不加载问天数据" }) });
|
|
return;
|
|
}
|
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(payload) });
|
|
});
|
|
}
|
|
|
|
test("admin shell opens every primary workspace and global search", async ({ page }) => {
|
|
await mockApplication(page);
|
|
await page.goto("/index.html");
|
|
await expect(page.locator("#authGate")).toBeHidden();
|
|
await expect(page.locator("#settingsButton")).toBeVisible();
|
|
await expect(page.locator("#syncButton")).toBeVisible();
|
|
await page.locator("#alertButton").click();
|
|
await expect(page.locator("#alertsDialog")).toBeVisible();
|
|
await page.locator("#closeAlertsDialog").click();
|
|
await page.locator("#assistantButton").click();
|
|
await expect(page.locator("#assistantDialog")).toBeVisible();
|
|
await page.locator("#closeAssistantDialog").click();
|
|
|
|
const views = [
|
|
"sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView",
|
|
"performanceView", "ladderView", "rotationView", "dragonView", "screenerView",
|
|
"mentorView", "heavenView", "reviewWorkspaceView",
|
|
];
|
|
for (const view of views) {
|
|
await page.locator(`[data-view="${view}"]`).first().click();
|
|
await expect(page.locator(`#${view}`)).toHaveClass(/active-view/);
|
|
}
|
|
|
|
await page.keyboard.press("Control+K");
|
|
await expect(page.locator("#globalSearchDialog")).toBeVisible();
|
|
await expect(page.locator("#globalSearchInput")).toBeFocused();
|
|
});
|
|
|
|
test("regular account cannot see admin controls and member features are gated", async ({ page }) => {
|
|
await mockApplication(page, session("user", false));
|
|
await page.goto("/index.html");
|
|
await expect(page.locator("#settingsButton")).toBeHidden();
|
|
await expect(page.locator("#syncButton")).toBeHidden();
|
|
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
|
await page.locator('[data-view="screenerView"]').first().click();
|
|
await expect(page.locator("#screenerView .member-gate")).toBeVisible();
|
|
await expect(page.locator("#screenerRunButton")).toBeDisabled();
|
|
await page.locator("#assistantButton").click();
|
|
await expect(page.locator("#settingsDialog")).toBeHidden();
|
|
await expect(page.locator("#assistantDialog")).toBeVisible();
|
|
await expect(page.locator("#assistantMemberGate")).toContainText("复盘助手仅对会员开放");
|
|
await expect(page.locator("#assistantMemberGate")).toBeVisible();
|
|
await expect(page.locator("#assistantMemberContent")).toHaveAttribute("aria-disabled", "true");
|
|
await expect(page.locator("#assistantQuestion")).toBeDisabled();
|
|
await expect(page.locator("[data-assistant-prompt]").first()).toBeDisabled();
|
|
await expect(page.locator("#sendAssistant")).toBeDisabled();
|
|
});
|
|
|
|
test("stock hover preview ignores the selected historical date", async ({ page }) => {
|
|
await mockApplication(page, session("user", true));
|
|
await page.goto("/index.html");
|
|
await page.locator("#tradeDate").fill("2026-07-01");
|
|
const requestPromise = page.waitForRequest((request) => request.url().includes("/api/stock/002141/preview"));
|
|
await page.evaluate(() => showStockPreview("002141", document.querySelector("#globalSearchButton")));
|
|
const request = await requestPromise;
|
|
const requestUrl = new URL(request.url());
|
|
expect(requestUrl.searchParams.has("trade_date")).toBe(false);
|
|
await expect(page.locator("#stockPreviewDate")).toHaveText("2026-07-23");
|
|
await expect(page.locator("#stockPreviewName")).toHaveText("Test Stock");
|
|
});
|
|
|
|
test("mobile shell stays within the viewport", async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await mockApplication(page, session("user", true));
|
|
await page.goto("/index.html");
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
|
expect(overflow).toBeLessThanOrEqual(1);
|
|
await expect(page.locator("#globalSearchButton")).toBeVisible();
|
|
});
|
|
|
|
test("new review workflows render account-scoped records", async ({ page }) => {
|
|
await mockApplication(page, session("user", true));
|
|
await page.goto("/index.html");
|
|
|
|
await page.locator('[data-view="screenerView"]').first().click();
|
|
await expect(page.locator("#trackingTableBody tr")).toHaveCount(1);
|
|
await expect(page.locator("#trackingEmpty")).toBeHidden();
|
|
|
|
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
|
|
await expect(page.locator("#tradeLogTableBody tr")).toHaveCount(1);
|
|
await expect(page.locator("#tradeLogEmpty")).toBeHidden();
|
|
|
|
await page.locator("#alertButton").click();
|
|
await expect(page.locator("#alertList .alert-item")).toHaveCount(1);
|
|
await expect(page.locator("#alertBadge")).toHaveText("1");
|
|
await page.locator("#closeAlertsDialog").click();
|
|
|
|
await page.locator("#assistantButton").click();
|
|
await expect(page.locator("#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("#tradeLogForm")).toBeVisible();
|
|
});
|
|
}
|
|
|
|
test("reduced-motion preference suppresses continuous animation", async ({ page }) => {
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await mockApplication(page, session("user", true));
|
|
await page.goto("/index.html");
|
|
expect(await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true);
|
|
const motion = await page.locator(".sentiment-gauge").evaluate((element) => {
|
|
const style = getComputedStyle(element, "::after");
|
|
return { duration: style.animationDuration, iterations: style.animationIterationCount };
|
|
});
|
|
expect(Number.parseFloat(motion.duration)).toBeLessThanOrEqual(0.00001);
|
|
expect(motion.iterations).toBe("1");
|
|
});
|