rebuild(audit): verify page state matrix
This commit is contained in:
@@ -6,6 +6,24 @@ const { expect, test } = require("@playwright/test");
|
||||
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-11");
|
||||
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
|
||||
|
||||
async function contrastRatio(locator) {
|
||||
return locator.evaluate((element) => {
|
||||
const rgba = (value) => (value.match(/[\d.]+/g) ?? []).map(Number);
|
||||
const luminance = (value) => {
|
||||
const [r, g, b] = rgba(value).slice(0, 3).map((channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
let background = element;
|
||||
while (background && rgba(getComputedStyle(background).backgroundColor)[3] === 0) background = background.parentElement;
|
||||
const foregroundLuminance = luminance(getComputedStyle(element).color);
|
||||
const backgroundLuminance = luminance(getComputedStyle(background ?? document.documentElement).backgroundColor);
|
||||
return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) / (Math.min(foregroundLuminance, backgroundLuminance) + 0.05);
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticate(page, username, password) {
|
||||
await page.goto("/");
|
||||
await page.getByLabel("账号名").fill(username);
|
||||
@@ -143,7 +161,17 @@ test("stage 11 Heaven workflows, animations and responsive layouts", async ({ pa
|
||||
|
||||
await page.clock.install();
|
||||
await page.locator(".heaven-mode-tabs button").filter({ hasText: "观心" }).click();
|
||||
const heartText = page.getByRole("heading", { name: "把所问之事留在心里" });
|
||||
const historyButton = page.getByRole("button", { name: "历史记录", exact: true }).first();
|
||||
const muteButton = page.getByRole("button", { name: "静音", exact: true });
|
||||
await expect(heartText).toBeVisible();
|
||||
expect(await contrastRatio(heartText)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(await contrastRatio(historyButton)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(await contrastRatio(muteButton)).toBeGreaterThanOrEqual(4.5);
|
||||
await page.getByRole("button", { name: "开始静心", exact: true }).click();
|
||||
const resetButton = page.getByRole("button", { name: "重新观心", exact: true });
|
||||
await expect(resetButton).toBeVisible();
|
||||
expect(await contrastRatio(resetButton)).toBeGreaterThanOrEqual(4.5);
|
||||
await page.clock.fastForward(1200);
|
||||
await expect(page.getByText("吸", { exact: true })).toBeVisible();
|
||||
await page.clock.fastForward(45_000);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
const { expect, test } = require("@playwright/test");
|
||||
|
||||
const marketKeys = [
|
||||
"emotion", "pool", "broken", "limit-down", "yesterday", "performance",
|
||||
"ladder", "rotation", "auction", "themes", "popularity", "dragon-list",
|
||||
];
|
||||
|
||||
const summary = {
|
||||
context: {
|
||||
requested_date: "2026-07-30", actual_date: "2026-07-30", previous_date: "2026-07-29",
|
||||
observed_at: "2026-07-30T15:00:00+08:00", state: "final", carried_forward: false, message: "",
|
||||
},
|
||||
values: { temperature: 42, limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 },
|
||||
};
|
||||
|
||||
async function authenticate(page) {
|
||||
await page.goto("/");
|
||||
await page.getByLabel("账号名").fill("stage4admin");
|
||||
await page.getByLabel("密码").fill("Stage4-pass-123!");
|
||||
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
async function delay(milliseconds = 180) {
|
||||
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function baseRoutes(page) {
|
||||
await page.route("**/api/market/summary", (route) => route.fulfill({
|
||||
contentType: "application/json", body: JSON.stringify(summary),
|
||||
}));
|
||||
await page.route("**/api/review/alerts?*", (route) => route.fulfill({
|
||||
contentType: "application/json", body: JSON.stringify({ items: [], unread_count: 0, as_of: "2026-07-30" }),
|
||||
}));
|
||||
}
|
||||
|
||||
test("all market workspaces expose loading, empty and failure truthfully", async ({ page }) => {
|
||||
let state = "empty";
|
||||
await baseRoutes(page);
|
||||
const respond = async (route) => {
|
||||
if (state === "loading-empty") await delay();
|
||||
if (state === "failure") return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "矩阵测试上游失败" }) });
|
||||
return route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "", message: "等待管理员首次同步真实收盘行情" }) });
|
||||
};
|
||||
await page.route("**/api/market/workspaces/*", respond);
|
||||
await page.route("**/api/market/insights/*", respond);
|
||||
await authenticate(page);
|
||||
|
||||
for (const key of marketKeys) {
|
||||
state = "loading-empty";
|
||||
await page.goto(`/workspace/${key}`);
|
||||
await expect(page.getByText("正在读取本地复盘数据", { exact: true }), `loading ${key}`).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "暂无真实行情快照" }), `empty ${key}`).toBeVisible();
|
||||
|
||||
state = "failure";
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: "页面数据暂不可用" }), `failure ${key}`).toBeVisible();
|
||||
await expect(page.getByText("请求失败,请稍后重试。", { exact: true }), `failure reason ${key}`).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("smart and private workspaces expose loading, empty and failure truthfully", async ({ page }) => {
|
||||
let state = "empty";
|
||||
await baseRoutes(page);
|
||||
|
||||
const response = async (route, payload) => {
|
||||
if (state === "loading-empty") await delay();
|
||||
if (state === "failure") return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "矩阵测试服务失败" }) });
|
||||
return route.fulfill({ contentType: "application/json", body: JSON.stringify(payload) });
|
||||
};
|
||||
const catalog = { factor_groups: {}, factors: {}, stage: [], curated: [] };
|
||||
const screener = { trade_date: "2026-07-30", message: "", catalog, stage_runs: [], curated_runs: [], custom_strategies: [], custom_runs: [] };
|
||||
const review = { trade_date: "2026-07-30", watchlist: [], daily: null, history: [], trades: [], trade_summary: { total: 0, realized: 0, win_rate: null, pnl_amount: null, average_position: null } };
|
||||
await page.route("**/api/screener/catalog", (route) => response(route, catalog));
|
||||
await page.route("**/api/screener?*", (route) => response(route, screener));
|
||||
await page.route("**/api/screener/tracks", (route) => response(route, []));
|
||||
await page.route("**/api/mentors/setup?*", (route) => response(route, { trade_date: "2026-07-30", mentors: [] }));
|
||||
await page.route("**/api/heaven/setup?*", (route) => response(route, { date: "2026-07-30", fortune: {}, daily_fortune: null, history: [] }));
|
||||
await page.route("**/api/review?*", (route) => response(route, review));
|
||||
await authenticate(page);
|
||||
|
||||
const cases = [
|
||||
{ path: "/workspace/screener", key: "screener", loading: "正在读取本地选股归档", empty: "等待盘后判定", failure: "智能选股暂不可用" },
|
||||
{ path: "/workspace/screener/tracking", key: "tracking", loading: "正在读取跟踪记录", empty: "暂无跟踪记录", failure: "跟踪记录暂不可用" },
|
||||
{ path: "/workspace/mentor", key: "mentor", loading: "正在读取思维模型", empty: "从问题出发,按模型的方法拆解市场。", failure: "问师暂不可用" },
|
||||
{ path: "/workspace/heaven", key: "heaven", loading: "正在推演当日基础气机", empty: "请输入股票代码或股票名称", failure: "请求失败,请稍后重试。" },
|
||||
{ path: "/workspace/review", key: "review", loading: "正在读取个人复盘记录", empty: "暂无自选", failure: "复盘记录暂不可用" },
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
state = "loading-empty";
|
||||
await page.goto(item.path);
|
||||
await expect(page.getByText(item.loading, { exact: true }), `loading ${item.key}`).toBeVisible();
|
||||
await expect(page.getByText(item.empty, { exact: true }).first(), `empty ${item.key}`).toBeVisible();
|
||||
|
||||
state = "failure";
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByText(item.failure, { exact: true }).first(), `failure ${item.key}`).toBeVisible();
|
||||
await expect(page.getByText("请求失败,请稍后重试。", { exact: true }).first(), `failure reason ${item.key}`).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("alerts and review assistant distinguish loading, empty and failure", async ({ page }) => {
|
||||
let state = "empty";
|
||||
await baseRoutes(page);
|
||||
const response = async (route, payload) => {
|
||||
if (state === "loading-empty") await delay();
|
||||
if (state === "failure") return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "不应暴露的上游正文" }) });
|
||||
return route.fulfill({ contentType: "application/json", body: JSON.stringify(payload) });
|
||||
};
|
||||
await page.route("**/api/review/alerts?*", (route) => response(route, { items: [], unread_count: 0, as_of: "2026-07-30" }));
|
||||
await page.route("**/api/review/assistant/messages", (route) => response(route, []));
|
||||
await authenticate(page);
|
||||
|
||||
state = "loading-empty";
|
||||
await page.getByRole("button", { name: "提醒中心" }).click();
|
||||
let dialog = page.getByRole("dialog", { name: "提醒中心" });
|
||||
await expect(dialog.getByText("正在读取提醒", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole("heading", { name: "暂无提醒" })).toBeVisible();
|
||||
await dialog.getByLabel("关闭").click();
|
||||
state = "failure";
|
||||
await page.getByRole("button", { name: "提醒中心" }).click();
|
||||
dialog = page.getByRole("dialog", { name: "提醒中心" });
|
||||
await expect(dialog.getByRole("heading", { name: "提醒暂不可用" })).toBeVisible();
|
||||
await expect(dialog.getByText("请求失败,请稍后重试。", { exact: true })).toBeVisible();
|
||||
await dialog.getByLabel("关闭").click();
|
||||
|
||||
state = "loading-empty";
|
||||
await page.getByRole("button", { name: "复盘助手" }).click();
|
||||
dialog = page.getByRole("dialog", { name: "复盘助手" });
|
||||
await expect(dialog.getByText("正在读取复盘助手对话", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText("可以从市场、策略或自己的交易记录开始复盘", { exact: true })).toBeVisible();
|
||||
await dialog.getByLabel("关闭").click();
|
||||
state = "failure";
|
||||
await page.getByRole("button", { name: "复盘助手" }).click();
|
||||
dialog = page.getByRole("dialog", { name: "复盘助手" });
|
||||
await expect(dialog.getByText("复盘助手对话暂不可用", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText("请求失败,请稍后重试。", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("system model and membership panels distinguish loading, empty and failure", async ({ page }) => {
|
||||
let state = "empty";
|
||||
await baseRoutes(page);
|
||||
const response = async (route, payload) => {
|
||||
if (state === "loading-empty") await delay();
|
||||
if (state === "failure") return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "不应暴露的系统错误" }) });
|
||||
return route.fulfill({ contentType: "application/json", body: JSON.stringify(payload) });
|
||||
};
|
||||
await page.route("**/api/admin/system/credentials", (route) => route.fulfill({ contentType: "application/json", body: "[]" }));
|
||||
await page.route("**/api/admin/operations/jobs?*", (route) => route.fulfill({ contentType: "application/json", body: "[]" }));
|
||||
await page.route("**/api/admin/models", (route) => response(route, []));
|
||||
await page.route("**/api/admin/memberships", (route) => response(route, []));
|
||||
await authenticate(page);
|
||||
await page.goto("/system");
|
||||
|
||||
state = "loading-empty";
|
||||
await page.getByRole("button", { name: "模型池", exact: true }).click();
|
||||
await expect(page.getByText("正在读取模型池", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("尚未添加模型", { exact: true })).toBeVisible();
|
||||
state = "failure";
|
||||
await page.getByRole("button", { name: "行情管理", exact: true }).click();
|
||||
await page.getByRole("button", { name: "模型池", exact: true }).click();
|
||||
await expect(page.getByText("请求失败,请稍后重试。", { exact: true }).first()).toBeVisible();
|
||||
|
||||
state = "loading-empty";
|
||||
await page.getByRole("button", { name: "会员管理", exact: true }).click();
|
||||
await expect(page.getByText("正在读取会员列表", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("请选择账号", { exact: true })).toBeVisible();
|
||||
state = "failure";
|
||||
await page.getByRole("button", { name: "行情管理", exact: true }).click();
|
||||
await page.getByRole("button", { name: "会员管理", exact: true }).click();
|
||||
await expect(page.getByText("请求失败,请稍后重试。", { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("account profile and membership dialogs distinguish loading, inactive and failure", async ({ page }) => {
|
||||
let state = "empty";
|
||||
await baseRoutes(page);
|
||||
const response = async (route, payload) => {
|
||||
if (state === "loading-empty") await delay();
|
||||
if (state === "failure") return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "不应暴露的账户错误" }) });
|
||||
return route.fulfill({ contentType: "application/json", body: JSON.stringify(payload) });
|
||||
};
|
||||
await page.route("**/api/account/profile", (route) => response(route, { configured: false, birth_date: null, birth_time: null, gender: null, privacy_notice: "原始出生资料加密保存,仅当前账号可见。" }));
|
||||
await page.route("**/api/account/membership", (route) => response(route, { status: "none", active: false, is_permanent: false, expires_at: null, remaining_days: null, daily_limit: 50, used_today: 0, remaining_today: 50, quota_exempt: false, smart_access: true, description: "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。", usage_package_available: false }));
|
||||
await authenticate(page);
|
||||
|
||||
const openAccountItem = async (name) => {
|
||||
await page.getByRole("button", { name: /stage4admin/ }).click();
|
||||
await page.locator(".account-menu").getByRole("button", { name, exact: true }).click();
|
||||
};
|
||||
|
||||
state = "loading-empty";
|
||||
await openAccountItem("个人资料");
|
||||
let dialog = page.getByRole("dialog", { name: "个人资料" });
|
||||
await expect(dialog.getByText("正在读取个人资料", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByLabel("出生日期")).toHaveValue("");
|
||||
await dialog.getByLabel("关闭").click();
|
||||
state = "failure";
|
||||
await openAccountItem("个人资料");
|
||||
dialog = page.getByRole("dialog", { name: "个人资料" });
|
||||
await expect(dialog.getByRole("alert")).toHaveText("请求失败,请稍后重试。");
|
||||
await dialog.getByLabel("关闭").click();
|
||||
|
||||
state = "loading-empty";
|
||||
await openAccountItem("会员状态");
|
||||
dialog = page.getByRole("dialog", { name: "会员状态" });
|
||||
await expect(dialog.getByText("正在读取会员状态", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText("未开通", { exact: true }).first()).toBeVisible();
|
||||
await dialog.getByLabel("关闭").click();
|
||||
state = "failure";
|
||||
await openAccountItem("会员状态");
|
||||
dialog = page.getByRole("dialog", { name: "会员状态" });
|
||||
await expect(dialog.getByRole("alert")).toHaveText("请求失败,请稍后重试。");
|
||||
});
|
||||
@@ -85,8 +85,21 @@ test("authentication, shell, dialogs, theme and responsive structure", async ({
|
||||
|
||||
await page.getByRole("button", { name: "夜间" }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
const switchedSurfaces = await page.evaluate(() => {
|
||||
const selectors = ["html", "body", ".sidebar", ".topbar", ".market-strip", ".card"];
|
||||
return selectors.map((selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
return { selector, background: element ? getComputedStyle(element).backgroundColor : null };
|
||||
});
|
||||
});
|
||||
expect(switchedSurfaces.every(({ background }) => background && background !== "rgb(255, 255, 255)")).toBe(true);
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
expect(await page.evaluate(() => ({
|
||||
theme: document.documentElement.dataset.theme,
|
||||
canvas: getComputedStyle(document.documentElement).backgroundColor,
|
||||
body: getComputedStyle(document.body).backgroundColor,
|
||||
}))).toEqual({ theme: "dark", canvas: "rgb(18, 20, 22)", body: "rgb(18, 20, 22)" });
|
||||
await expect(page.getByRole("button", { name: "日间" })).toBeVisible();
|
||||
await page.screenshot({ path: path.join(evidence, "shell-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ function chartPayload(interval) {
|
||||
|
||||
test("latest snapshot, grouped search, chart preview and entity detail", async ({ page }) => {
|
||||
const consoleErrors = [];
|
||||
let delayStockChart = false;
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) {
|
||||
consoleErrors.push(message.text());
|
||||
@@ -86,7 +87,8 @@ test("latest snapshot, grouped search, chart preview and entity detail", async (
|
||||
const interval = route.request().url().endsWith("/minute") ? "minute" : "day";
|
||||
return route.fulfill({ contentType: "application/json", body: JSON.stringify(chartPayload(interval)) });
|
||||
});
|
||||
await page.route("**/api/market/entities/stock/000001.SZ/charts/*", (route) => {
|
||||
await page.route("**/api/market/entities/stock/000001.SZ/charts/*", async (route) => {
|
||||
if (delayStockChart) await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const interval = route.request().url().endsWith("/minute") ? "minute" : "day";
|
||||
return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...chartPayload(interval), entity_type: "stock", identifier: "000001.SZ", code: "000001", name: "平安银行" }) });
|
||||
});
|
||||
@@ -177,6 +179,22 @@ test("latest snapshot, grouped search, chart preview and entity detail", async (
|
||||
await page.getByRole("button", { name: "加入自选" }).click();
|
||||
await expect(page.getByRole("button", { name: "移出自选" })).toBeVisible();
|
||||
|
||||
delayStockChart = true;
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".preview-state")).toHaveText("正在读取真实行情");
|
||||
const loadingSurfaces = await page.evaluate(() => ({
|
||||
theme: document.documentElement.dataset.theme,
|
||||
canvas: getComputedStyle(document.documentElement).backgroundColor,
|
||||
preview: getComputedStyle(document.querySelector(".market-preview")).backgroundColor,
|
||||
loading: getComputedStyle(document.querySelector(".preview-state")).backgroundColor,
|
||||
}));
|
||||
expect(loadingSurfaces.theme).toBe("dark");
|
||||
expect(loadingSurfaces.canvas).not.toBe("rgb(255, 255, 255)");
|
||||
expect(loadingSurfaces.preview).not.toBe("rgb(255, 255, 255)");
|
||||
expect(loadingSurfaces.loading).not.toBe("rgb(255, 255, 255)");
|
||||
delayStockChart = false;
|
||||
await expect(page.locator(".market-chart")).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.locator(".market-chart")).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
|
||||
|
||||
@@ -126,6 +126,18 @@ test("emotion and pool workspaces remain usable across desktop and mobile", asyn
|
||||
await page.getByRole("link", { name: /涨停表现/ }).click();
|
||||
await expect(page.getByText("7板")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日结论" })).toBeVisible();
|
||||
const performanceGeometry = await page.evaluate(() => {
|
||||
const layout = document.querySelector(".performance-layout").getBoundingClientRect();
|
||||
const levels = document.querySelector(".performance-levels").getBoundingClientRect();
|
||||
const conclusion = document.querySelector(".performance-conclusion").getBoundingClientRect();
|
||||
return {
|
||||
cards: document.querySelectorAll(".performance-level-card").length,
|
||||
topGap: Math.abs(levels.top - conclusion.top),
|
||||
conclusionRightGap: Math.abs(layout.right - conclusion.right),
|
||||
overflow: document.documentElement.scrollWidth - window.innerWidth,
|
||||
};
|
||||
});
|
||||
expect(performanceGeometry).toEqual({ cards: 7, topGap: 0, conclusionRightGap: 0, overflow: 0 });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
|
||||
await page.screenshot({ path: path.join(evidence, "performance-dark-390x844.jpg"), type: "jpeg", quality: 82 });
|
||||
|
||||
Reference in New Issue
Block a user