事故根因:HEL-214/221/226/233 均直接基于 main(8a5e78f) 构建部署,
绕过了 .11 正式线(HEL-183/188/190/191/192/193/199/207/208 + HEL-164
共 14 个已验收提交),导致问天工具行右对齐、侧栏等高、管理员刷新
结果、快照补档、收盘日线修复等整体丢失。
本合并以 deploy 前最后完整基线 89b8d33(镜像 official-limit-guard-d9ee725)
为底,合入 cefc869(HEL-221 情绪周期等高、HEL-226 登录门户与免密切换、
HEL-233 移动端系统管理五页),三处 CSS 缓存版本统一刷新为 20260829-hel237,
architecture-inventory 按合并后源码重新生成。
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -47,6 +47,72 @@ function session(role = "admin", subscribed = true) {
|
||||
};
|
||||
}
|
||||
|
||||
function sentimentHistoryPayload(days = 20) {
|
||||
const phases = ["冰点", "修复", "发酵", "高潮", "分化", "退潮"];
|
||||
const rows = Array.from({ length: days }, (_, index) => {
|
||||
const score = 28 + ((index * 7) % 55);
|
||||
return {
|
||||
trade_date: `2026-08-${String(index + 1).padStart(2, "0")}`,
|
||||
score,
|
||||
label: "情绪观察",
|
||||
phase: phases[index % phases.length],
|
||||
direction: index % 2 ? "升温" : "降温",
|
||||
day_change: index % 2 ? 3.2 : -2.1,
|
||||
seal_rate: 71.5,
|
||||
limit_up_count: 40 + index,
|
||||
first_board_count: 18,
|
||||
second_board_count: 8,
|
||||
three_plus_count: 4,
|
||||
max_height: 5,
|
||||
broken_count: 12,
|
||||
limit_down_count: 3,
|
||||
previous_limit_count: 38,
|
||||
previous_positive_count: 22,
|
||||
previous_positive_rate: 57.9,
|
||||
average_previous_change: 1.2,
|
||||
normalization: "固定锚点",
|
||||
components: {
|
||||
breadth: { label: "市场宽度", score: 55.8, weight: 20, summary: "红盘家数回升" },
|
||||
limit: { label: "涨停连板", score: 79.6, weight: 25, summary: "连板生态改善" },
|
||||
profit: { label: "赚钱效应", score: 67.0, weight: 30, summary: "昨日反馈尚可" },
|
||||
ladder: { label: "涨幅结构", score: 81.5, weight: 15, summary: "高度仍在扩张" },
|
||||
amount: { label: "成交活跃度", score: 46.1, weight: 10, summary: "量能略低于均值" },
|
||||
},
|
||||
};
|
||||
});
|
||||
return { available_days: days, rows };
|
||||
}
|
||||
|
||||
async function renderSentimentFixture(page, days = 20) {
|
||||
await page.evaluate((payload) => {
|
||||
state.sentimentHistory = payload;
|
||||
renderSentimentHistory();
|
||||
}, sentimentHistoryPayload(days));
|
||||
await page.locator('[data-view="sentimentCycleView"]').first().click();
|
||||
await page.waitForTimeout(900);
|
||||
}
|
||||
|
||||
function readSentimentLayout() {
|
||||
const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect();
|
||||
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
|
||||
const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect();
|
||||
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
|
||||
const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect();
|
||||
const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect();
|
||||
const table = document.querySelector(".sentiment-history-frame").getBoundingClientRect();
|
||||
const small = document.querySelector(".sentiment-component-item small");
|
||||
return {
|
||||
topDelta: Math.abs(trend.top - summary.top),
|
||||
bottomDelta: Math.abs(trend.bottom - components.bottom),
|
||||
analysisHeight: analysis.height,
|
||||
chartHeight: chart.height,
|
||||
detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom) - 0.5,
|
||||
tableVisible: table.top < window.innerHeight && table.bottom > detail.bottom,
|
||||
smallVisible: small ? getComputedStyle(small).display !== "none" : false,
|
||||
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function waitForApplicationRuntime(page) {
|
||||
return expect(page.locator("body")).toHaveAttribute("data-runtime-ready", "true");
|
||||
}
|
||||
@@ -721,6 +787,7 @@ test("collapsed overview and sentiment layout keep a single current reading", as
|
||||
await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0);
|
||||
await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。");
|
||||
const alignment = await page.evaluate(() => {
|
||||
const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect();
|
||||
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
|
||||
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
|
||||
const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect();
|
||||
@@ -732,6 +799,8 @@ test("collapsed overview and sentiment layout keep a single current reading", as
|
||||
const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text"));
|
||||
return {
|
||||
columnsAligned: Math.abs(trend.top - summary.top) < 1,
|
||||
bottomsAligned: Math.abs(trend.bottom - components.bottom) < 1,
|
||||
analysisHeight: analysis.height,
|
||||
railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom,
|
||||
detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom),
|
||||
chartHeight: chart.height,
|
||||
@@ -743,6 +812,8 @@ test("collapsed overview and sentiment layout keep a single current reading", as
|
||||
};
|
||||
});
|
||||
expect(alignment.columnsAligned).toBe(true);
|
||||
expect(alignment.bottomsAligned).toBe(true);
|
||||
expect(Math.abs(alignment.analysisHeight - 600)).toBeLessThanOrEqual(1);
|
||||
expect(alignment.railAligned).toBe(true);
|
||||
expect(alignment.detailAfterAnalysis).toBe(true);
|
||||
expect(alignment.chartHeight).toBeGreaterThanOrEqual(340);
|
||||
@@ -780,6 +851,68 @@ test("collapsed overview and sentiment layout keep a single current reading", as
|
||||
}
|
||||
});
|
||||
|
||||
test("sentiment cycle keeps 600px equal-height layout across zoom viewports", async ({ page }, testInfo) => {
|
||||
const shotDir = testInfo.outputPath("hel-221-shots");
|
||||
await mockApplication(page, session("user", true));
|
||||
const viewports = [
|
||||
{ name: "zoom-100", width: 2560, height: 1440 },
|
||||
{ name: "zoom-110", width: 2327, height: 1309 },
|
||||
{ name: "zoom-125", width: 2048, height: 1152 },
|
||||
];
|
||||
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await page.goto("/index.html");
|
||||
await renderSentimentFixture(page, 20);
|
||||
const layout = await page.evaluate(readSentimentLayout);
|
||||
expect(layout.topDelta, viewport.name).toBeLessThanOrEqual(1);
|
||||
expect(layout.bottomDelta, viewport.name).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(layout.analysisHeight - 600), viewport.name).toBeLessThanOrEqual(1);
|
||||
expect(layout.chartHeight, viewport.name).toBeGreaterThanOrEqual(450);
|
||||
expect(layout.detailAfterAnalysis, viewport.name).toBe(true);
|
||||
expect(layout.tableVisible, viewport.name).toBe(true);
|
||||
expect(layout.smallVisible, viewport.name).toBe(true);
|
||||
expect(layout.overflowX, viewport.name).toBe(false);
|
||||
await page.screenshot({
|
||||
path: `${shotDir}/day-${viewport.name}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 2560, height: 1440 });
|
||||
await page.locator("#themeToggle").click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await page.waitForTimeout(200);
|
||||
const nightLayout = await page.evaluate(readSentimentLayout);
|
||||
expect(nightLayout.topDelta).toBeLessThanOrEqual(1);
|
||||
expect(nightLayout.bottomDelta).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(nightLayout.analysisHeight - 600)).toBeLessThanOrEqual(1);
|
||||
await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } });
|
||||
const tooltip = page.locator("#sentimentChartTooltip");
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText("温度");
|
||||
const tooltipStyle = await tooltip.evaluate((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
const bold = getComputedStyle(node.querySelector("b") || node);
|
||||
return { background: style.backgroundColor, color: style.color, bold: bold.color };
|
||||
});
|
||||
expect(tooltipStyle.background).toBe("rgb(38, 41, 62)");
|
||||
expect(tooltipStyle.color).toBe("rgb(232, 234, 237)");
|
||||
expect(tooltipStyle.bold).toBe("rgb(232, 234, 237)");
|
||||
await page.screenshot({ path: `${shotDir}/night-zoom-100.png`, fullPage: true });
|
||||
|
||||
await page.locator("#themeToggle").click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||
await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } });
|
||||
await expect(tooltip).toBeVisible();
|
||||
const lightTooltip = await tooltip.evaluate((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
return { background: style.backgroundColor, color: style.color };
|
||||
});
|
||||
expect(lightTooltip.background).toBe("rgb(31, 35, 41)");
|
||||
expect(lightTooltip.color).toBe("rgb(255, 255, 255)");
|
||||
});
|
||||
|
||||
test("limit-up pool separates stock identity and restores the reason column", async ({ page }) => {
|
||||
await mockApplication(page, session("user", true));
|
||||
await page.goto("/index.html");
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
function loginPayload(user) {
|
||||
return {
|
||||
ok: true,
|
||||
authenticated: true,
|
||||
csrf_token: "portal-csrf",
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
async function mockLoginPortal(page, options = {}) {
|
||||
const accounts = options.accounts || [];
|
||||
let currentUserId = options.currentUserId ?? null;
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const method = route.request().method();
|
||||
if (url.pathname === "/api/auth/accounts") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true, accounts, current_user_id: currentUserId }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/auth/switch" && method === "POST") {
|
||||
const body = route.request().postDataJSON() || {};
|
||||
const account = accounts.find((item) => Number(item.user_id) === Number(body.user_id));
|
||||
if (!account || options.switchFails) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "该账号需重新验证" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
currentUserId = account.user_id;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(loginPayload(account)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/auth/forget" && method === "POST") {
|
||||
const body = route.request().postDataJSON() || {};
|
||||
const index = accounts.findIndex((item) => Number(item.user_id) === Number(body.user_id));
|
||||
if (index >= 0) accounts.splice(index, 1);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if ((url.pathname === "/api/auth/login" || url.pathname === "/api/auth/register") && method === "POST") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(loginPayload({
|
||||
id: 9,
|
||||
username: "new_user",
|
||||
role: "user",
|
||||
membership: { active: false, subscribed: false, is_admin: false },
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/auth/me") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
authenticated: Boolean(currentUserId),
|
||||
csrf_token: "portal-csrf",
|
||||
user: accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const SAVED_ACCOUNTS = [
|
||||
{
|
||||
user_id: 1,
|
||||
username: "alpha_user",
|
||||
role: "admin",
|
||||
membership: { active: true, subscribed: true, is_admin: true },
|
||||
last_used_at: "2026-08-29T01:00:00+00:00",
|
||||
},
|
||||
{
|
||||
user_id: 2,
|
||||
username: "beta_user",
|
||||
role: "user",
|
||||
membership: { active: false, subscribed: false, is_admin: false },
|
||||
last_used_at: "2026-08-28T01:00:00+00:00",
|
||||
},
|
||||
];
|
||||
|
||||
test("first-time login portal asks for a password and hides environment copy", async ({ page }) => {
|
||||
await mockLoginPortal(page, { accounts: [] });
|
||||
await page.goto("/login/");
|
||||
await expect(page.locator(".login-card-title")).toHaveText("欢迎回来");
|
||||
await expect(page.locator("#loginUsername")).toBeVisible();
|
||||
await expect(page.locator(".login-submit")).toHaveText("登录");
|
||||
await expect(page.locator("body")).not.toContainText("内网个人版");
|
||||
await expect(page.locator("body")).not.toContainText("192.168.200.11");
|
||||
});
|
||||
|
||||
test("saved accounts can switch directly and show a re-auth message on failure", async ({ page }) => {
|
||||
await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })) });
|
||||
await page.goto("/login/");
|
||||
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
|
||||
await expect(page.locator(".login-account-row")).toHaveCount(2);
|
||||
const switched = page.waitForRequest((request) => (
|
||||
request.url().includes("/api/auth/switch") && request.method() === "POST"
|
||||
));
|
||||
await page.locator('[data-switch-id="2"]').click();
|
||||
const request = await switched;
|
||||
expect(JSON.parse(request.postData() || "{}")).toEqual({ user_id: 2 });
|
||||
});
|
||||
|
||||
test("failed account switch stays on the portal with the original copy", async ({ page }) => {
|
||||
await mockLoginPortal(page, {
|
||||
accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })),
|
||||
switchFails: true,
|
||||
});
|
||||
await page.goto("/login/");
|
||||
await page.locator('[data-switch-id="2"]').click();
|
||||
await expect(page.locator(".login-error")).toHaveText("该账号需重新验证");
|
||||
await expect(page).toHaveURL(/\/login\/?/);
|
||||
});
|
||||
|
||||
test("managing accounts removes a local record after inline confirmation", async ({ page }) => {
|
||||
await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })) });
|
||||
await page.goto("/login/");
|
||||
await page.locator('[data-login-action="manage"]').click();
|
||||
await expect(page.locator(".login-card-title")).toHaveText("管理账号记录");
|
||||
await page.locator('[data-confirm-id="2"]').click();
|
||||
await expect(page.locator(".login-confirm-copy")).toContainText("beta_user");
|
||||
await page.locator('[data-forget-id="2"]').click();
|
||||
await expect(page.locator(".login-account-row")).toHaveCount(1);
|
||||
await expect(page.locator(".login-account-row")).toContainText("alpha_user");
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
// 手机端(/m/)全页面回归:P5 收官打磨。
|
||||
// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手,
|
||||
// 手机端(/m/)全页面回归:P5 收官打磨 + HEL-233 系统管理恢复。
|
||||
// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手、系统管理 5 页,
|
||||
// 以及日夜两套渲染、空态、错误态、横屏健壮性、深底深字对比度抽查。
|
||||
|
||||
const EMPTY_DASHBOARD = {
|
||||
@@ -138,6 +138,31 @@ async function mockMobileApi(page, options = {}) {
|
||||
payload = { items: [] };
|
||||
} else if (path === "/api/search") {
|
||||
payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "贤丰控股", type: "stock", industry: "电子元件" }], sectors: [], themes: [], indices: [] } };
|
||||
} else if (path === "/api/account/status") {
|
||||
payload = {
|
||||
birth_profile_configured: true,
|
||||
birth_profile: { birth_datetime: "1990-01-15T08:30", gender: "male" },
|
||||
llm_access: {
|
||||
daily_limit: 50,
|
||||
used_today: 3,
|
||||
remaining_calls: 47,
|
||||
membership: {
|
||||
active: true,
|
||||
subscribed: true,
|
||||
is_admin: auth.user.role === "admin",
|
||||
remaining_days: 20,
|
||||
expires_at: "2026-09-18",
|
||||
plan: "会员",
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (path === "/api/admin/settings") {
|
||||
payload = {
|
||||
data: { configured: true, snapshot_dates: 12, background_refresh_enabled: true, ifind: { configured: false } },
|
||||
llm: { models: [{ id: "model-1", name: "主模型", base_url: "https://api.openai.com/v1", model: "gpt-4.1", configured: true }], primary_model_id: "model-1", fallback_model_id: "" },
|
||||
membership: { member_daily_limit: 50 },
|
||||
users: [{ id: 2, username: "normal_user", role: "user", membership_subscribed: true, membership_status: "active", membership_expires_at: "2026-09-18", used_today: 3 }],
|
||||
};
|
||||
} else if (/^\/api\/stock\/\d+\/preview$/.test(path)) {
|
||||
payload = {
|
||||
meta: { trade_date: "2026-07-22", intraday_status: "available" },
|
||||
@@ -230,6 +255,16 @@ const REVIEW_PAGES = [
|
||||
["review/alerts", "提醒中心"],
|
||||
];
|
||||
|
||||
const SYSTEM_PAGES = [
|
||||
["system/profile", "账号资料"],
|
||||
["system/password", "修改密码"],
|
||||
["system/membership", "会员状态"],
|
||||
["system/admin", "系统设置"],
|
||||
["system/members", "会员管理"],
|
||||
];
|
||||
|
||||
const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
|
||||
|
||||
test("mobile login renders before authentication", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.route("**/api/**", async (route) => {
|
||||
@@ -296,6 +331,54 @@ test("review five pages render watchlist, trades, daily, notes and alerts", asyn
|
||||
}
|
||||
});
|
||||
|
||||
test("system management pages render real content instead of placeholders", async ({ page }) => {
|
||||
await mockMobileApi(page);
|
||||
await openMobile(page);
|
||||
for (const [key, label] of SYSTEM_PAGES) {
|
||||
await navigateToFeature(page, key);
|
||||
await expect(page.locator("#m-title")).toHaveText(label);
|
||||
await expect(page.locator(".m-placeholder")).toHaveCount(0);
|
||||
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
||||
await expect(page.locator("[data-system-page], [data-system-admin-panel]").first()).toBeVisible();
|
||||
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
await navigateToFeature(page, "system/profile");
|
||||
await expect(page.locator("#m-sys-birth-date")).toBeVisible();
|
||||
await expect(page.locator("[data-system-switch]")).toBeVisible();
|
||||
await navigateToFeature(page, "system/password");
|
||||
await expect(page.locator("#m-sys-password-current")).toBeVisible();
|
||||
await navigateToFeature(page, "system/membership");
|
||||
await expect(page.locator(".m-sys-grid")).toBeVisible();
|
||||
await navigateToFeature(page, "system/admin");
|
||||
await expect(page.locator("#m-sys-token")).toBeVisible();
|
||||
await navigateToFeature(page, "system/members");
|
||||
await expect(page.locator("#m-sys-member-limit")).toBeVisible();
|
||||
});
|
||||
|
||||
test("empty profile save click shows a toast instead of a dead button", async ({ page }) => {
|
||||
await mockMobileApi(page);
|
||||
await openMobile(page);
|
||||
await navigateToFeature(page, "system/profile");
|
||||
await expect(page.locator("[data-system-save-birth]")).toBeVisible();
|
||||
await page.locator("#m-sys-birth-date").fill("");
|
||||
await page.locator("#m-sys-birth-time").fill("");
|
||||
await page.locator("[data-system-save-birth]").click();
|
||||
await expect(page.locator("#m-toast.is-visible")).toBeVisible();
|
||||
await expect(page.locator("#m-toast")).toContainText("请填写完整出生日期和时间");
|
||||
});
|
||||
|
||||
test("non-admin cannot open system admin pages as placeholders", async ({ page }) => {
|
||||
await mockMobileApi(page, { auth: authSession("user", true) });
|
||||
await openMobile(page);
|
||||
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
|
||||
await expect(page.locator(".m-hub-grid")).toBeVisible();
|
||||
await expect(page.locator('.m-grid-item[data-route="#/feature/system/admin"]')).toHaveCount(0);
|
||||
await expect(page.locator('.m-grid-item[data-route="#/feature/system/members"]')).toHaveCount(0);
|
||||
await navigateToFeature(page, "system/admin");
|
||||
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
||||
await expect(page.locator("[data-system-page='forbidden']")).toBeVisible();
|
||||
});
|
||||
|
||||
test("assistant chat renders with presets and input", async ({ page }) => {
|
||||
await mockMobileApi(page);
|
||||
await openMobile(page);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from backend.features.accounts.security import SecretVault, token_hash
|
||||
from backend.features.accounts.service import AccountService
|
||||
from database import ReviewDatabase
|
||||
|
||||
|
||||
class AccountSwitchGrantTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = TemporaryDirectory()
|
||||
self.database = ReviewDatabase(Path(self.temp.name) / "review.db")
|
||||
self.bound_user_id = 0
|
||||
self.service = AccountService(
|
||||
database=self.database,
|
||||
vault=SecretVault(SecretVault.generate_key()),
|
||||
current_user_supplier=lambda: self.bound_user_id,
|
||||
access_supplier=lambda: self.database.user_access(self.bound_user_id) or {},
|
||||
bind_user=self._bind,
|
||||
personal_field_builder=lambda *args, **kwargs: {},
|
||||
auth_lock=threading.Lock(),
|
||||
)
|
||||
self.device_a = token_hash("device-a-token")
|
||||
self.device_b = token_hash("device-b-token")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
|
||||
def _bind(self, user_id: int) -> None:
|
||||
self.bound_user_id = int(user_id)
|
||||
|
||||
def _register(self, username: str, device_hash: str = "") -> dict:
|
||||
return self.service.register(username, "Password123", device_hash or self.device_a)
|
||||
|
||||
def test_login_records_accounts_for_the_current_device_only(self) -> None:
|
||||
first = self._register("alpha_user")
|
||||
second = self._register("beta_user")
|
||||
self.service.login("alpha_user", "Password123", self.device_b)
|
||||
|
||||
listed = self.service.list_device_accounts(self.device_a)
|
||||
names = [item["username"] for item in listed["accounts"]]
|
||||
self.assertEqual(names, ["beta_user", "alpha_user"])
|
||||
self.assertEqual(
|
||||
self.service.list_device_accounts(self.device_b)["accounts"][0]["username"],
|
||||
"alpha_user",
|
||||
)
|
||||
self.assertEqual(self.service.list_device_accounts("")["accounts"], [])
|
||||
self.assertEqual(first["user"]["username"], "alpha_user")
|
||||
self.assertEqual(second["user"]["username"], "beta_user")
|
||||
|
||||
def test_switch_uses_device_grant_and_keeps_the_original_authorization(self) -> None:
|
||||
first = self._register("alpha_user")
|
||||
self._register("beta_user")
|
||||
switched = self.service.switch_account(self.device_a, int(first["user"]["id"]))
|
||||
self.assertEqual(switched["user"]["username"], "alpha_user")
|
||||
remaining = {
|
||||
item["username"]
|
||||
for item in self.service.list_device_accounts(self.device_a)["accounts"]
|
||||
}
|
||||
self.assertEqual(remaining, {"alpha_user", "beta_user"})
|
||||
|
||||
def test_switch_without_a_valid_grant_requires_reauthentication(self) -> None:
|
||||
user = self._register("alpha_user")
|
||||
with self.assertRaisesRegex(PermissionError, "该账号需重新验证"):
|
||||
self.service.switch_account(self.device_b, int(user["user"]["id"]))
|
||||
with self.assertRaisesRegex(PermissionError, "该账号需重新验证"):
|
||||
self.service.switch_account("", int(user["user"]["id"]))
|
||||
|
||||
def test_forget_only_removes_the_current_device_grant(self) -> None:
|
||||
user = self._register("alpha_user")
|
||||
self.service.login("alpha_user", "Password123", self.device_b)
|
||||
self.service.forget_account(self.device_a, int(user["user"]["id"]))
|
||||
self.service.forget_account(self.device_a, int(user["user"]["id"]))
|
||||
self.assertEqual(self.service.list_device_accounts(self.device_a)["accounts"], [])
|
||||
self.assertEqual(
|
||||
self.service.list_device_accounts(self.device_b)["accounts"][0]["username"],
|
||||
"alpha_user",
|
||||
)
|
||||
|
||||
def test_logout_revokes_only_the_current_account_on_this_device(self) -> None:
|
||||
first = self._register("alpha_user")
|
||||
second = self._register("beta_user")
|
||||
self.service.revoke_current_device_grant(self.device_a, int(second["user"]["id"]))
|
||||
names = {
|
||||
item["username"]
|
||||
for item in self.service.list_device_accounts(self.device_a)["accounts"]
|
||||
}
|
||||
self.assertEqual(names, {"alpha_user"})
|
||||
switched = self.service.switch_account(self.device_a, int(first["user"]["id"]))
|
||||
self.assertEqual(switched["user"]["id"], first["user"]["id"])
|
||||
|
||||
def test_password_change_revokes_grants_on_every_device(self) -> None:
|
||||
user = self._register("alpha_user")
|
||||
self.service.login("alpha_user", "Password123", self.device_b)
|
||||
self._bind(int(user["user"]["id"]))
|
||||
self.service.change_password("Password123", "Password456")
|
||||
self.assertEqual(self.service.list_device_accounts(self.device_a)["accounts"], [])
|
||||
self.assertEqual(self.service.list_device_accounts(self.device_b)["accounts"], [])
|
||||
with self.assertRaisesRegex(PermissionError, "该账号需重新验证"):
|
||||
self.service.switch_account(self.device_a, int(user["user"]["id"]))
|
||||
|
||||
def test_device_keeps_at_most_five_accounts(self) -> None:
|
||||
usernames = [f"user_{index}" for index in range(6)]
|
||||
ids = [self._register(name)["user"]["id"] for name in usernames]
|
||||
listed = self.service.list_device_accounts(self.device_a)["accounts"]
|
||||
self.assertEqual(len(listed), 5)
|
||||
kept = {item["user_id"] for item in listed}
|
||||
self.assertNotIn(ids[0], kept)
|
||||
self.assertTrue(set(ids[1:]).issubset(kept))
|
||||
|
||||
def test_expired_grants_are_removed_lazily(self) -> None:
|
||||
user = self._register("alpha_user")
|
||||
past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(timespec="seconds")
|
||||
self.database.upsert_switch_grant(
|
||||
self.device_a,
|
||||
int(user["user"]["id"]),
|
||||
past,
|
||||
past,
|
||||
past,
|
||||
)
|
||||
self.assertEqual(self.service.list_device_accounts(self.device_a)["accounts"], [])
|
||||
with self.assertRaisesRegex(PermissionError, "该账号需重新验证"):
|
||||
self.service.switch_account(self.device_a, int(user["user"]["id"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -25,6 +25,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
("0002", "create_job_runs"),
|
||||
("0003", "extend_llm_audit"),
|
||||
("0004", "add_mentor_note"),
|
||||
("0005", "create_account_switch_grants"),
|
||||
],
|
||||
)
|
||||
columns = {
|
||||
@@ -39,7 +40,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) AS count FROM schema_migrations"
|
||||
).fetchone()["count"]
|
||||
self.assertEqual(count, 4)
|
||||
self.assertEqual(count, 5)
|
||||
|
||||
def test_database_with_recorded_0004_and_note_column_starts_without_reapply(
|
||||
self,
|
||||
@@ -60,7 +61,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) AS count FROM schema_migrations"
|
||||
).fetchone()["count"]
|
||||
self.assertEqual(count, 4)
|
||||
self.assertEqual(count, 5)
|
||||
|
||||
def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
@@ -87,7 +88,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
"PRAGMA table_info(mentor_preferences)"
|
||||
)
|
||||
]
|
||||
self.assertEqual(versions, {"0001", "0002", "0003", "0004"})
|
||||
self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005"})
|
||||
self.assertIn("note", note_rows)
|
||||
|
||||
def test_database_with_unknown_migration_is_rejected(self) -> None:
|
||||
|
||||
@@ -344,6 +344,22 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertIn("max-height: var(--sentiment-history-max-height);", self.sentiment_styles)
|
||||
self.assertIn("overflow: auto;", self.sentiment_styles)
|
||||
|
||||
def test_sentiment_equal_height_and_tooltip_tokens(self):
|
||||
self.assertIn("--sentiment-analysis-height: 600px;", self.tokens)
|
||||
self.assertIn("--sentiment-tooltip-bg: var(--text-primary);", self.tokens)
|
||||
self.assertIn("--sentiment-tooltip-fg: var(--text-inverse);", self.tokens)
|
||||
self.assertIn("--sentiment-tooltip-bg: #26293e;", self.tokens)
|
||||
self.assertIn("--sentiment-tooltip-fg: #e8eaed;", self.tokens)
|
||||
self.assertIn("height: var(--sentiment-analysis-height);", self.sentiment_styles)
|
||||
self.assertIn("max-height: var(--sentiment-analysis-height);", self.sentiment_styles)
|
||||
self.assertIn("justify-content: space-evenly;", self.sentiment_styles)
|
||||
self.assertIn("background: var(--sentiment-tooltip-bg);", self.sentiment_styles)
|
||||
self.assertIn("color: var(--sentiment-tooltip-fg);", self.sentiment_styles)
|
||||
self.assertIn("new ResizeObserver", self.script)
|
||||
self.assertIn("#sentimentCycleView .redesigned-emotion-grid {", self.sentiment_styles)
|
||||
self.assertNotIn("height: 100vh", self.sentiment_styles)
|
||||
self.assertNotIn("min-height: 100%", self.sentiment_styles)
|
||||
|
||||
def test_mentor_final_visual_fix_contract(self):
|
||||
shell_styles = (STATIC_DIR / "shared" / "shell.css").read_text(encoding="utf-8")
|
||||
mentor_html = (STATIC_DIR / "pages" / "mentor" / "page.html").read_text(encoding="utf-8")
|
||||
|
||||
@@ -65,8 +65,11 @@ class GovernanceRegistryTests(unittest.TestCase):
|
||||
public,
|
||||
{
|
||||
("GET", "/api/health"),
|
||||
("GET", "/api/auth/accounts"),
|
||||
("POST", "/api/auth/login"),
|
||||
("POST", "/api/auth/register"),
|
||||
("POST", "/api/auth/switch"),
|
||||
("POST", "/api/auth/forget"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
NAV = ROOT / "frontend/m/config/nav.config.js"
|
||||
PAGES = ROOT / "frontend/m/js/pages.js"
|
||||
ROUTER = ROOT / "frontend/m/js/router.js"
|
||||
|
||||
|
||||
class MobileSystemPagesRegressionTests(unittest.TestCase):
|
||||
"""Prevent mobile system-management entries from falling back to placeholders."""
|
||||
|
||||
def test_nav_system_entries_are_registered_as_real_pages(self) -> None:
|
||||
nav = NAV.read_text(encoding="utf-8")
|
||||
pages = PAGES.read_text(encoding="utf-8")
|
||||
keys = re.findall(r'key:\s*"(system/[^"]+)"', nav)
|
||||
self.assertEqual(
|
||||
keys,
|
||||
[
|
||||
"system/profile",
|
||||
"system/password",
|
||||
"system/membership",
|
||||
"system/admin",
|
||||
"system/members",
|
||||
],
|
||||
)
|
||||
for key in keys:
|
||||
self.assertIn(f'"{key}": setupSystemPage', pages)
|
||||
self.assertIn(f'"{key}": loadSystem', pages)
|
||||
|
||||
def test_placeholder_copy_is_only_a_router_fallback(self) -> None:
|
||||
router = ROUTER.read_text(encoding="utf-8")
|
||||
pages = PAGES.read_text(encoding="utf-8")
|
||||
self.assertIn("该功能页将在后续批次实现", router)
|
||||
self.assertNotIn("该功能页将在后续批次实现", pages)
|
||||
self.assertIn("function setupSystemPage", pages)
|
||||
self.assertIn("function loadSystem", pages)
|
||||
|
||||
def test_system_pages_render_real_controls_not_stubs(self) -> None:
|
||||
pages = PAGES.read_text(encoding="utf-8")
|
||||
for marker in (
|
||||
'data-system-page="profile"',
|
||||
'data-system-page="password"',
|
||||
'data-system-page="membership"',
|
||||
'data-system-page="members"',
|
||||
'data-system-page="forbidden"',
|
||||
'data-system-admin-panel="market"',
|
||||
"m-sys-birth-date",
|
||||
"m-sys-password-current",
|
||||
"m-sys-token",
|
||||
"m-sys-member-limit",
|
||||
"data-system-switch",
|
||||
'location.assign("/login/")',
|
||||
):
|
||||
self.assertIn(marker, pages)
|
||||
|
||||
def test_system_boolean_attrs_do_not_have_stray_quotes(self) -> None:
|
||||
pages = PAGES.read_text(encoding="utf-8")
|
||||
stray = re.findall(r'data-system-[a-z-]*"(?=[>\s])', pages)
|
||||
self.assertEqual(
|
||||
stray,
|
||||
[],
|
||||
"boolean data-system attributes must not have a trailing quote before > or space",
|
||||
)
|
||||
for name in (
|
||||
"data-system-save-birth",
|
||||
"data-system-save-password",
|
||||
"data-system-add-model",
|
||||
"data-system-save-models",
|
||||
"data-system-save-market",
|
||||
"data-system-refresh",
|
||||
):
|
||||
self.assertIn(name, pages)
|
||||
self.assertNotIn(name + '">', pages)
|
||||
Reference in New Issue
Block a user