rebuild(audit): verify page state matrix

This commit is contained in:
leefer
2026-07-30 11:12:37 +08:00
parent b79b4ba280
commit 4eafa1cc3f
13 changed files with 386 additions and 37 deletions
+28
View File
@@ -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);
+214
View File
@@ -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("请求失败,请稍后重试。");
});
+13
View File
@@ -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 });
+19 -1
View File
@@ -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);
+12
View File
@@ -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 });