chore: complete stage 19 regression hardening

This commit is contained in:
leefer
2026-07-28 23:34:10 +08:00
parent 1cc80583b3
commit c30d2107b3
6 changed files with 179 additions and 41 deletions
+48
View File
@@ -0,0 +1,48 @@
# Stage 19 Acceptance Record
Date: 2026-07-28
Baseline: `1cc8058`
## Scope
- Whole-site desktop, compact desktop, and mobile regression.
- Light and dark theme regression.
- Account permissions, API contracts, cache behavior, and critical workflows.
- Native dialog lifecycle and feedback sizing.
- Removal of CSS rules proven obsolete by the current product contract.
## Changes
- All native dialogs now open through one lifecycle helper. Opening a dialog closes any unrelated open dialog first, while each dialog's existing close cleanup remains intact.
- The obsolete compact sentiment-chart override was removed. The approved 350px chart geometry now applies at 1080P and compact desktop heights, with the page owning vertical scrolling.
- The heart incense now runs the 45-second progress movement and the glow animation together.
- Stale tests were aligned with current behavior: 5-board-plus grouping, rotation constituents, theme-aware charts, deterministic breathing phases, member gating, custom screener entry, and A/B/C-only mentor grades.
- Unused sentiment compact-height tokens were removed after reference scanning.
## Automated Verification
- Playwright: 43 tests, covering all primary workspaces, themes, desktop/mobile layouts, dialogs, permissions, screeners, mentor workflows, and Wentian interactions.
- Python unittest: 163 tests, covering account boundaries, access policy, caches, market data, iFinD adapters, screeners, strategy tracking, review workflows, and Wentian calculations.
- JavaScript syntax: `static/app.js` and `static/heaven-loading-v2.js` checked with `node --check`.
- Patch hygiene: `git diff --check`.
## Browser Verification
- 1920x1080 dark: sentiment chart 350px high; no horizontal overflow; full-page scrolling owns the history section.
- 1365x768 dark: sentiment chart remains 350px; the main area scrolls; the status bar remains fixed and unobstructed.
- 1365x768 light: chart, cards, table, and shell switch together without white dark-theme remnants.
- 1440x900 light: nine-day rotation matrix fits without horizontal scrolling; custom screener and result table fit the canonical shell.
- 390x844 light: no horizontal overflow; fixed header ends before main content; Wentian remains vertically scrollable above the bottom navigation.
## Design Specification Checklist
- Tables retain content-based columns, right-aligned tabular numbers, units in headers, blank missing values, and sortable numeric headers where supported.
- All 16 primary workspaces share the same desktop content origin and width within one rendered pixel.
- The shell remains 200px sidebar, 46px topbar, 34px summary strip, 14px/16px content padding, and 30px status bar.
- Page titles, card gaps, empty states, chart legends, dates, and status-bar context remain present.
- Wentian keeps its isolated effects and color treatment while using the same outer shell geometry.
## Residual Risk
The CSS stack still contains older page-specific layers. They were not bulk-deleted because selector overlap alone does not prove a rule is dead. Future cleanup should remove a rule only after reference scanning, browser comparison, and full regression testing.
+22 -14
View File
@@ -204,6 +204,14 @@ const elements = {
stockPreviewChart: document.querySelector("#stockPreviewChart"), stockPreviewChart: document.querySelector("#stockPreviewChart"),
}; };
function openModalDialog(dialog) {
if (!(dialog instanceof HTMLDialogElement)) return;
document.querySelectorAll("dialog[open]").forEach((openDialog) => {
if (openDialog !== dialog) openDialog.close();
});
if (!dialog.open) dialog.showModal();
}
const metricAnimationFrames = new WeakMap(); const metricAnimationFrames = new WeakMap();
const stockPreviewCache = new Map(); const stockPreviewCache = new Map();
const STOCK_PREVIEW_DELAY = 380; const STOCK_PREVIEW_DELAY = 380;
@@ -3076,7 +3084,7 @@ function openWatchlistDialog(item = null) {
document.querySelector("#watchlistSearchInput").value = ""; document.querySelector("#watchlistSearchInput").value = "";
document.querySelector("#watchlistSearchResults").innerHTML = ""; document.querySelector("#watchlistSearchResults").innerHTML = "";
syncWatchlistSelection(Boolean(item)); syncWatchlistSelection(Boolean(item));
if (!elements.watchlistDialog.open) elements.watchlistDialog.showModal(); openModalDialog(elements.watchlistDialog);
requestAnimationFrame(() => (item ? document.querySelector("#watchlistRemark") : document.querySelector("#watchlistSearchInput")).focus()); requestAnimationFrame(() => (item ? document.querySelector("#watchlistRemark") : document.querySelector("#watchlistSearchInput")).focus());
} }
@@ -3240,7 +3248,7 @@ function populateJournalForm() {
function openTradeLogDialog() { function openTradeLogDialog() {
resetTradeLogForm(); resetTradeLogForm();
if (!elements.tradeLogDialog.open) elements.tradeLogDialog.showModal(); openModalDialog(elements.tradeLogDialog);
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus()); requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
} }
@@ -3311,7 +3319,7 @@ function editTradeLog(id) {
document.querySelector("#tradeLogExecution").value = item.execution || ""; document.querySelector("#tradeLogExecution").value = item.execution || "";
setText("tradeLogDialogTitle", "编辑交易日志"); setText("tradeLogDialogTitle", "编辑交易日志");
setText("saveTradeLog", "保存修改"); setText("saveTradeLog", "保存修改");
if (!elements.tradeLogDialog.open) elements.tradeLogDialog.showModal(); openModalDialog(elements.tradeLogDialog);
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus()); requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
} }
@@ -4067,7 +4075,7 @@ function renderScreenerProgress() {
function openStrategyDrawer(target = "editor") { function openStrategyDrawer(target = "editor") {
const drawer = document.querySelector("#strategyDrawer"); const drawer = document.querySelector("#strategyDrawer");
if (!drawer.open) drawer.showModal(); openModalDialog(drawer);
requestAnimationFrame(() => { requestAnimationFrame(() => {
const focusTarget = target === "library" const focusTarget = target === "library"
? document.querySelector("#strategyList .strategy-item.active") || document.querySelector("#strategyList .strategy-item") ? document.querySelector("#strategyList .strategy-item.active") || document.querySelector("#strategyList .strategy-item")
@@ -5889,7 +5897,7 @@ function openHeavenReading(mode, options = {}) {
? Boolean(options.loading) ? Boolean(options.loading)
: false; : false;
renderHeavenReadingDialog(); renderHeavenReadingDialog();
if (!elements.heavenReadingDialog.open) elements.heavenReadingDialog.showModal(); openModalDialog(elements.heavenReadingDialog);
requestAnimationFrame(() => { requestAnimationFrame(() => {
syncHeavenReadingAnimation(); syncHeavenReadingAnimation();
document.querySelector("#closeHeavenReadingDialog").focus(); document.querySelector("#closeHeavenReadingDialog").focus();
@@ -5901,7 +5909,7 @@ async function openHeavenHistory(mode) {
state.heavenReadingTab = "history"; state.heavenReadingTab = "history";
state.heavenReadingSelectedId = 0; state.heavenReadingSelectedId = 0;
renderHeavenReadingDialog(); renderHeavenReadingDialog();
if (!elements.heavenReadingDialog.open) elements.heavenReadingDialog.showModal(); openModalDialog(elements.heavenReadingDialog);
await loadHeavenReadingHistory(mode); await loadHeavenReadingHistory(mode);
} }
@@ -7570,7 +7578,7 @@ async function loadAlerts(openDialog = false) {
state.alerts = payload.items || []; state.alerts = payload.items || [];
state.alertUnreadCount = number(payload.unread_count); state.alertUnreadCount = number(payload.unread_count);
renderAlerts(); renderAlerts();
if (openDialog && !elements.alertsDialog.open) elements.alertsDialog.showModal(); if (openDialog) openModalDialog(elements.alertsDialog);
} catch (error) { } catch (error) {
if (openDialog) showToast(error.message || "提醒加载失败"); if (openDialog) showToast(error.message || "提醒加载失败");
} }
@@ -7580,7 +7588,7 @@ function openAlerts() {
toggleHeaderCommandMenu(false); toggleHeaderCommandMenu(false);
toggleAccountDropdown(false); toggleAccountDropdown(false);
document.querySelector("#alertDate").value ||= todayString(); document.querySelector("#alertDate").value ||= todayString();
elements.alertsDialog.showModal(); openModalDialog(elements.alertsDialog);
loadAlerts(); loadAlerts();
} }
@@ -7688,7 +7696,7 @@ function renderAlerts() {
async function openReviewAssistant() { async function openReviewAssistant() {
toggleHeaderCommandMenu(false); toggleHeaderCommandMenu(false);
toggleAccountDropdown(false); toggleAccountDropdown(false);
if (!elements.assistantDialog.open) elements.assistantDialog.showModal(); openModalDialog(elements.assistantDialog);
updateAssistantControls(); updateAssistantControls();
if (!hasMemberAccess()) { if (!hasMemberAccess()) {
document.querySelector("#closeAssistantDialog").focus(); document.querySelector("#closeAssistantDialog").focus();
@@ -7841,7 +7849,7 @@ function updateAssistantControls() {
function openGlobalSearch() { function openGlobalSearch() {
if (!state.user) return; if (!state.user) return;
toggleHeaderCommandMenu(false); toggleHeaderCommandMenu(false);
if (!elements.globalSearchDialog.open) elements.globalSearchDialog.showModal(); openModalDialog(elements.globalSearchDialog);
requestAnimationFrame(() => { requestAnimationFrame(() => {
elements.globalSearchInput.focus(); elements.globalSearchInput.focus();
elements.globalSearchInput.select(); elements.globalSearchInput.select();
@@ -7984,7 +7992,7 @@ async function openEntityDetail(item) {
setText("entityDetailDate", "正在加载行情"); setText("entityDetailDate", "正在加载行情");
document.querySelector("#entityDetailChange").className = ""; document.querySelector("#entityDetailChange").className = "";
document.querySelector("#entityDetailMetrics").innerHTML = '<div class="empty-state">正在加载交易数据</div>'; document.querySelector("#entityDetailMetrics").innerHTML = '<div class="empty-state">正在加载交易数据</div>';
if (!elements.entityDetailDialog.open) elements.entityDetailDialog.showModal(); openModalDialog(elements.entityDetailDialog);
clearEntityDetailChart("正在加载日 K 数据"); clearEntityDetailChart("正在加载日 K 数据");
try { try {
const params = new URLSearchParams({ type: item.type, id: item.id, trade_date: elements.tradeDate.value }); const params = new URLSearchParams({ type: item.type, id: item.id, trade_date: elements.tradeDate.value });
@@ -8209,7 +8217,7 @@ async function openStock(code, fallback = null) {
document.querySelector("#stockNotePlan").value = ""; document.querySelector("#stockNotePlan").value = "";
document.querySelector("#stockNotes").innerHTML = '<div class="empty-state">正在加载笔记</div>'; document.querySelector("#stockNotes").innerHTML = '<div class="empty-state">正在加载笔记</div>';
updateWatchButton(); updateWatchButton();
if (!elements.stockDialog.open) elements.stockDialog.showModal(); openModalDialog(elements.stockDialog);
clearPriceChart("正在加载日 K 数据"); clearPriceChart("正在加载日 K 数据");
try { try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
@@ -8508,7 +8516,7 @@ async function openSettings(panel = "profile") {
const status = document.querySelector("#connectionStatus"); const status = document.querySelector("#connectionStatus");
status.className = "connection-status"; status.className = "connection-status";
status.textContent = "正在读取账号状态"; status.textContent = "正在读取账号状态";
if (!elements.settingsDialog.open) elements.settingsDialog.showModal(); openModalDialog(elements.settingsDialog);
try { try {
const payload = await apiRequest("/api/account/status"); const payload = await apiRequest("/api/account/status");
const access = payload.llm_access || {}; const access = payload.llm_access || {};
@@ -8587,7 +8595,7 @@ async function switchAccount() {
async function openAdminSettings(refreshOnly = false) { async function openAdminSettings(refreshOnly = false) {
if (state.user?.role !== "admin") return; if (state.user?.role !== "admin") return;
if (!refreshOnly && !elements.adminDialog.open) elements.adminDialog.showModal(); if (!refreshOnly) openModalDialog(elements.adminDialog);
const status = document.querySelector("#adminConnectionStatus"); const status = document.querySelector("#adminConnectionStatus");
status.textContent = "正在读取系统状态"; status.textContent = "正在读取系统状态";
try { try {
-4
View File
@@ -30,8 +30,6 @@
--right-rail-wide:372px; --right-rail-wide:372px;
--pool-table-max-height:calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap)); --pool-table-max-height:calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap));
--sentiment-history-max-height:510px; --sentiment-history-max-height:510px;
--sentiment-chart-compact-height:clamp(180px,20dvh,250px);
--sentiment-chart-compact-canvas-height:calc(var(--sentiment-chart-compact-height) - 18px);
--sentiment-history-min-height:220px; --sentiment-history-min-height:220px;
--primary-share:1.45fr; --primary-share:1.45fr;
--secondary-share:.75fr; --secondary-share:.75fr;
@@ -912,8 +910,6 @@ tbody tr.clickable{cursor:pointer}
} }
@media (min-width:721px) and (max-height:1100px){ @media (min-width:721px) and (max-height:1100px){
#sentimentCycleView .sentiment-chart-shell{height:var(--sentiment-chart-compact-height)}
#sentimentCycleView .sentiment-chart-shell canvas{height:var(--sentiment-chart-compact-canvas-height)}
#sentimentCycleView .sentiment-phase-block{gap:12px;padding:10px 12px} #sentimentCycleView .sentiment-phase-block{gap:12px;padding:10px 12px}
#sentimentCycleView .sentiment-current-phase-badge{padding:8px 12px} #sentimentCycleView .sentiment-current-phase-badge{padding:8px 12px}
#sentimentCycleView .sentiment-phase-advice{margin-top:4px;padding:4px 8px;line-height:1.4} #sentimentCycleView .sentiment-phase-advice{margin-top:4px;padding:4px 8px;line-height:1.4}
+1 -1
View File
@@ -996,7 +996,7 @@ button { cursor: pointer; }
.heart-incense::after { content: "一炷香"; display: block; position: absolute; top: calc(100% + 14px); left: 50%; color: var(--wt-faint); font-family: var(--heaven-serif); font-size: 10px; letter-spacing: .22em; white-space: nowrap; transform: translateX(-50%); } .heart-incense::after { content: "一炷香"; display: block; position: absolute; top: calc(100% + 14px); left: 50%; color: var(--wt-faint); font-family: var(--heaven-serif); font-size: 10px; letter-spacing: .22em; white-space: nowrap; transform: translateX(-50%); }
.heart-incense i { width: 10px; height: 10px; position: absolute; top: 0; left: 50%; margin: -5px 0 0 -5px; border-radius: 50%; background: radial-gradient(circle,#ffd9a0 0,#e08840 45%,transparent 75%); box-shadow: 0 0 14px 4px rgba(255,180,90,.35); transform: none; } .heart-incense i { width: 10px; height: 10px; position: absolute; top: 0; left: 50%; margin: -5px 0 0 -5px; border-radius: 50%; background: radial-gradient(circle,#ffd9a0 0,#e08840 45%,transparent 75%); box-shadow: 0 0 14px 4px rgba(255,180,90,.35); transform: none; }
.heart-incense i::after { content: ""; width: 8px; height: 22px; position: absolute; bottom: 5px; left: 50%; border-radius: 50%; background: rgba(216,210,189,.18); filter: blur(4px); opacity: 0; transform: translateX(-50%); } .heart-incense i::after { content: ""; width: 8px; height: 22px; position: absolute; bottom: 5px; left: 50%; border-radius: 50%; background: rgba(216,210,189,.18); filter: blur(4px); opacity: 0; transform: translateX(-50%); }
.heart-incense i.is-burning { animation: heart-incense-glow 1.8s ease-in-out infinite; } .heart-incense i.is-burning { animation: heart-incense-burn 45s linear 1s forwards,heart-incense-glow 1.8s ease-in-out infinite; }
.heart-incense i.is-burning::after { animation: heart-incense-smoke 2.2s ease-out infinite; } .heart-incense i.is-burning::after { animation: heart-incense-smoke 2.2s ease-out infinite; }
@keyframes heart-incense-glow { 0%,100% { box-shadow: 0 0 10px 3px rgba(255,180,90,.26); } 50% { box-shadow: 0 0 18px 6px rgba(255,180,90,.5); } } @keyframes heart-incense-glow { 0%,100% { box-shadow: 0 0 10px 3px rgba(255,180,90,.26); } 50% { box-shadow: 0 0 18px 6px rgba(255,180,90,.5); } }
@keyframes heart-incense-smoke { 0% { opacity: 0; transform: translate(-50%,0) scale(.65); } 28% { opacity: .55; } 100% { opacity: 0; transform: translate(-70%,-28px) scale(1.2); } } @keyframes heart-incense-smoke { 0% { opacity: 0; transform: translate(-50%,0) scale(.65); } 28% { opacity: .55; } 100% { opacity: 0; transform: translate(-70%,-28px) scale(1.2); } }
+106 -21
View File
@@ -259,6 +259,16 @@ async function mockApplication(page, authSession = session(), options = {}) {
}; };
} else if (url.pathname === "/api/sentiment/history") payload = { rows: [], components: [] }; } 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/rotation/history") payload = { days: [], rows: [], sectors: [] };
else if (url.pathname === "/api/rotation/members") {
payload = {
meta: { trade_date: "20260724", sector_name: url.searchParams.get("sector") || "电网设备", member_count: 3, quoted_count: 2 },
rows: [
{ code: "002879", name: "长缆科技", change: 4.8, open: 18.21, close: 19.06, amount_billion: 19.6, quoted: true },
{ code: "603221", name: "爱丽家居", change: 1.2, open: 13.05, close: 13.22, amount_billion: 8.7, quoted: true },
{ code: "000001", name: "停牌样本", change: null, open: null, close: null, amount_billion: null, quoted: false },
],
};
}
else if (url.pathname === "/api/auction") { else if (url.pathname === "/api/auction") {
payload = { payload = {
meta: { trade_date: "2026-07-22", carried_forward: false, phase: "finalized", available: true, actionable: false }, meta: { trade_date: "2026-07-22", carried_forward: false, phase: "finalized", available: true, actionable: false },
@@ -476,6 +486,29 @@ test("admin shell opens every primary workspace and global search", async ({ pag
await expect(page.locator("#globalSearchInput")).toBeFocused(); await expect(page.locator("#globalSearchInput")).toBeFocused();
}); });
test("every primary workspace shares the canonical desktop shell geometry", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
const views = [
"auctionView", "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView",
"performanceView", "ladderView", "rotationView", "themeLibraryView", "popularityView", "dragonView",
"screenerView", "mentorView", "heavenView", "reviewWorkspaceView",
];
let reference = null;
for (const view of views) {
await page.locator(`[data-view="${view}"]`).first().click();
const activeView = page.locator(`#${view}`);
await expect(activeView).not.toHaveClass(/view-entering/);
const box = await activeView.boundingBox();
expect(box).not.toBeNull();
reference ||= { x: box.x, y: box.y, width: box.width };
expect(Math.abs(box.x - reference.x)).toBeLessThanOrEqual(1);
expect(Math.abs(box.y - reference.y)).toBeLessThanOrEqual(1);
expect(Math.abs(box.width - reference.width)).toBeLessThanOrEqual(1);
}
});
test("manual refresh stays in place without reopening the full-page loader", async ({ page }) => { test("manual refresh stays in place without reopening the full-page loader", async ({ page }) => {
const options = { dashboardDelay: 350 }; const options = { dashboardDelay: 350 };
await mockApplication(page, session(), options); await mockApplication(page, session(), options);
@@ -842,7 +875,7 @@ test("limit-up performance transfers the approved tier cards and market conclusi
await expect(page.locator("#performanceView")).toHaveClass(/redesigned-performance-view/); await expect(page.locator("#performanceView")).toHaveClass(/redesigned-performance-view/);
await expect(page.locator("#performanceDateRange")).toHaveText("昨日 2026-07-21 → 今日 2026-07-22"); await expect(page.locator("#performanceDateRange")).toHaveText("昨日 2026-07-21 → 今日 2026-07-22");
await expect(page.locator("#performanceCards .performance-stage-card")).toHaveCount(5); await expect(page.locator("#performanceCards .performance-stage-card")).toHaveCount(5);
await expect(page.locator("#performanceCards .performance-stage-card").first()).toContainText("昨日6板 → 今日"); await expect(page.locator("#performanceCards .performance-stage-card").first()).toContainText("昨日5板+ → 今日");
await expect(page.locator("#performanceCards .performance-stage-card").first()).toContainText("失效"); await expect(page.locator("#performanceCards .performance-stage-card").first()).toContainText("失效");
await expect(page.locator("#performanceCards .performance-stage-card").nth(2)).toContainText("50.0%"); await expect(page.locator("#performanceCards .performance-stage-card").nth(2)).toContainText("50.0%");
await expect(page.locator("#performanceCards .performance-stage-card").nth(2)).toContainText("活跃"); await expect(page.locator("#performanceCards .performance-stage-card").nth(2)).toContainText("活跃");
@@ -944,7 +977,7 @@ test("sector rotation transfers the nine-day matrix, tracking and sortable detai
{ name: "电力", change: -1.6 }, { name: "电力", change: -1.6 },
]; ];
renderRotationHistory(); renderRotationHistory();
renderRotationTable(state.dashboard.sector_rotation, state.dashboard.sectors); renderRotationMembers();
}); });
await page.locator('[data-view="rotationView"]').first().click(); await page.locator('[data-view="rotationView"]').first().click();
@@ -980,13 +1013,14 @@ test("sector rotation transfers the nine-day matrix, tracking and sortable detai
await page.locator('[data-rotation-sector="电网设备"]').first().click(); await page.locator('[data-rotation-sector="电网设备"]').first().click();
await expect(page.locator("#rotationTracker")).toBeVisible(); await expect(page.locator("#rotationTracker")).toBeVisible();
await expect(page.locator("#rotationTracker")).toContainText("近 9 日在榜 9 天"); await expect(page.locator("#rotationTracker")).toContainText("近 9 日在榜 9 天");
await expect(page.locator("#rotationTableBody tr").first()).toHaveClass(/selected/); await expect(page.locator("#rotationDetailTitle")).toHaveText("电网设备成分股");
await expect(page.locator("#rotationDetailMeta")).toContainText("2 / 3 只");
await expect(page.locator("#rotationTableBody tr")).toHaveCount(3);
await expect(page.locator("#rotationTableBody tr").first()).toContainText("长缆科技");
await expect(page.locator("#rotationTableBody tr").last()).toContainText("当日无行情");
await page.locator("#rotationTracker .rotation-track-cancel").click(); await page.locator("#rotationTracker .rotation-track-cancel").click();
await expect(page.locator("#rotationTracker")).toBeHidden(); await expect(page.locator("#rotationTracker")).toBeHidden();
await expect(page.locator("#rotationMembersEmpty")).toContainText("点击上方任意板块查看成分股");
await expect(page.locator("#rotationTableBody .trend-new")).toContainText("新进");
await page.locator('#rotationTable th[title^="变化"]').click();
await expect(page.locator("#rotationTableBody tr").first()).toContainText("-7");
await page.locator('[data-rotation-order="latest"]').click(); await page.locator('[data-rotation-order="latest"]').click();
await expect(page.locator("#rotationHistory .rotation-day").first()).toContainText("07-24"); await expect(page.locator("#rotationHistory .rotation-day").first()).toContainText("07-24");
await expect(page.locator("#rotationHistoryRange")).toContainText("由近到远,左侧为最新交易日"); await expect(page.locator("#rotationHistoryRange")).toContainText("由近到远,左侧为最新交易日");
@@ -1433,7 +1467,7 @@ test("regular account cannot see admin controls and member features are gated",
await expect(page.locator("#accountVipLabel")).toHaveText("非会员"); await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
await page.locator('[data-view="screenerView"]').first().click(); await page.locator('[data-view="screenerView"]').first().click();
await expect(page.locator("#screenerView .member-gate")).toBeVisible(); await expect(page.locator("#screenerView .member-gate")).toBeVisible();
await page.locator('[data-screener-mode="quant"]').click(); await expect(page.locator('[data-screener-mode="quant"]')).toBeDisabled();
await expect(page.locator("#quantRunButton")).toBeDisabled(); await expect(page.locator("#quantRunButton")).toBeDisabled();
await page.locator("#assistantButton").click(); await page.locator("#assistantButton").click();
await expect(page.locator("#settingsDialog")).toBeHidden(); await expect(page.locator("#settingsDialog")).toBeHidden();
@@ -1481,7 +1515,7 @@ test("rising candle body stays hollow and its wick stops at both edges", async (
canvas.width = 40; canvas.width = 40;
canvas.height = 80; canvas.height = 80;
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
context.fillStyle = CHART_BACKGROUND; context.fillStyle = currentChartPalette().background;
context.fillRect(0, 0, 40, 80); context.fillRect(0, 0, 40, 80);
const priceY = (value) => 90 - value * 8; const priceY = (value) => 90 - value * 8;
drawCandlestick(context, 20, { high: 10, close: 8, open: 6, low: 4 }, priceY, 10); drawCandlestick(context, 20, { high: 10, close: 8, open: 6, low: 4 }, priceY, 10);
@@ -1639,21 +1673,18 @@ test("heart breathing prepares once then contracts on each exhale", async ({ pag
const timing = await page.evaluate(() => ({ const timing = await page.evaluate(() => ({
remaining: state.heartBreathingEndsAt - Date.now(), remaining: state.heartBreathingEndsAt - Date.now(),
incenseDuration: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration, incenseNames: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationName,
incenseDelay: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDelay, incenseDurations: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration,
rippleDuration: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDuration, incenseDelays: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDelay,
rippleDelay: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDelay,
rippleAnimation: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationName, 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).toBeGreaterThan(45_000);
expect(timing.remaining).toBeLessThanOrEqual(46_000); expect(timing.remaining).toBeLessThanOrEqual(46_000);
expect(timing.incenseDuration).toBe("45s"); expect(timing.incenseNames).toContain("heart-incense-burn");
expect(timing.incenseDelay).toBe("1s"); expect(timing.incenseNames).toContain("heart-incense-glow");
expect(timing.rippleDuration).toBe("9s"); expect(timing.incenseDurations).toContain("45s");
expect(timing.rippleDelay).toBe("1s"); expect(timing.incenseDelays).toContain("1s");
expect(timing.rippleAnimation).toContain("heart-breath-ripple"); expect(timing.rippleAnimation).toBe("none");
expect(timing.rippleKeyframes.at(-1).transform).toContain("0.62");
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "prepare"); await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "prepare");
await expect(page.locator("#breathingPhase")).toHaveText("静"); await expect(page.locator("#breathingPhase")).toHaveText("静");
await expect(page.locator("#breathingSeconds, #breathingProgress, .breathing-orbit")).toHaveCount(0); await expect(page.locator("#breathingSeconds, #breathingProgress, .breathing-orbit")).toHaveCount(0);
@@ -1667,6 +1698,8 @@ test("heart breathing prepares once then contracts on each exhale", async ({ pag
}); });
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "inhale"); await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "inhale");
await expect(page.locator("#breathingPhase")).toHaveText("吸"); await expect(page.locator("#breathingPhase")).toHaveText("吸");
await expect(page.locator(".heart-breath-ripple span").first()).toHaveCSS("transition-duration", "3s");
await expect(page.locator(".heart-breath-ripple span").first()).toHaveCSS("transform", /matrix\(1, 0, 0, 1,/);
await page.evaluate(() => { await page.evaluate(() => {
state.heartSeconds = 42; state.heartSeconds = 42;
@@ -1683,6 +1716,7 @@ test("heart breathing prepares once then contracts on each exhale", async ({ pag
}); });
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "exhale"); await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "exhale");
await expect(page.locator("#breathingPhase")).toHaveText("呼"); await expect(page.locator("#breathingPhase")).toHaveText("呼");
await expect(page.locator(".heart-breath-ripple span").first()).toHaveCSS("transition-duration", "4s");
}); });
test("mobile shell stays within the viewport", async ({ page }) => { test("mobile shell stays within the viewport", async ({ page }) => {
@@ -1692,6 +1726,56 @@ test("mobile shell stays within the viewport", async ({ page }) => {
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1); expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator("#globalSearchButton")).toBeVisible(); await expect(page.locator("#globalSearchButton")).toBeVisible();
const mobileShell = await page.evaluate(() => {
const header = document.querySelector(".topbar").getBoundingClientRect();
const main = document.querySelector(".app-main").getBoundingClientRect();
return { headerBottom: header.bottom, mainTop: main.top };
});
expect(mobileShell.mainTop).toBeGreaterThanOrEqual(mobileShell.headerBottom - 1);
});
test("native dialogs share one lifecycle and success feedback stays content-sized", async ({ page }) => {
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
const dialogIds = [
"strategyDrawer",
"tradeLogDialog",
"watchlistDialog",
"heavenReadingDialog",
"globalSearchDialog",
"entityDetailDialog",
"stockDialog",
"alertsDialog",
"assistantDialog",
"settingsDialog",
"adminDialog",
];
await page.evaluate(() => {
openModalDialog(document.querySelector("#tradeLogDialog"));
openModalDialog(document.querySelector("#watchlistDialog"));
});
await expect(page.locator("dialog[open]")).toHaveCount(1);
await expect(page.locator("#watchlistDialog")).toBeVisible();
await expect(page.locator("#tradeLogDialog")).toBeHidden();
await page.keyboard.press("Escape");
await expect(page.locator("dialog[open]")).toHaveCount(0);
await page.locator('[data-view="screenerView"]').first().click();
await page.locator('[data-screener-mode="quant"]').click();
for (const id of dialogIds) {
await page.evaluate((dialogId) => openModalDialog(document.getElementById(dialogId)), id);
await expect(page.locator(`#${id}`)).toBeVisible();
await expect(page.locator(`#${id} button[aria-label*="关闭"]`).first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator(`#${id}`)).toBeHidden();
}
await page.evaluate(() => showToast("交易记录已保存"));
const toastBox = await page.locator("#toast").boundingBox();
expect(toastBox.width).toBeLessThan(260);
expect(toastBox.height).toBeLessThan(80);
}); });
test("new review workflows render account-scoped records", async ({ page }) => { test("new review workflows render account-scoped records", async ({ page }) => {
@@ -1701,6 +1785,7 @@ test("new review workflows render account-scoped records", async ({ page }) => {
await page.locator('[data-view="screenerView"]').first().click(); await page.locator('[data-view="screenerView"]').first().click();
await expect(page.locator('[data-screener-step="regime"]')).toHaveAttribute("data-state", "complete"); await expect(page.locator('[data-screener-step="regime"]')).toHaveAttribute("data-state", "complete");
await page.locator('[data-screener-mode="quant"]').click();
await page.locator("#openStrategyDrawerButton").click(); await page.locator("#openStrategyDrawerButton").click();
await expect(page.locator("#strategyDrawer")).toBeVisible(); await expect(page.locator("#strategyDrawer")).toBeVisible();
await expect(page.locator("#strategyNameInput")).toBeFocused(); await expect(page.locator("#strategyNameInput")).toBeFocused();
@@ -2314,7 +2399,7 @@ test("mentor directory exposes evidence filters and private owner metadata", asy
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(7); await expect(page.locator("#mentorList .mentor-option")).toHaveCount(7);
await page.locator('#mentorList [data-mentor-id="source-b"]').click(); await page.locator('#mentorList [data-mentor-id="source-b"]').click();
await expect(page.locator("#activeMentorName")).toHaveText("多源老师"); await expect(page.locator("#activeMentorName")).toHaveText("多源老师");
await expect(page.locator("#activeMentorBadges")).toContainText("B · 多源整理"); await expect(page.locator("#activeMentorBadges")).toHaveText("B");
await expect(page.locator("#activeMentorEvidence")).toHaveText("公开访谈与多源材料"); await expect(page.locator("#activeMentorEvidence")).toHaveText("公开访谈与多源材料");
const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox(); const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox();
const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox(); const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox();
+2 -1
View File
@@ -296,7 +296,8 @@ class FrontendContractTests(unittest.TestCase):
def test_trade_log_editor_is_dialog_based(self): def test_trade_log_editor_is_dialog_based(self):
self.assertIn('id="openTradeLogDialog"', self.html) self.assertIn('id="openTradeLogDialog"', self.html)
self.assertIn('id="tradeLogDialog" class="settings-dialog trade-log-dialog"', self.html) self.assertIn('id="tradeLogDialog" class="settings-dialog trade-log-dialog"', self.html)
self.assertIn('elements.tradeLogDialog.showModal()', self.script) self.assertIn('openModalDialog(elements.tradeLogDialog)', self.script)
self.assertIn('document.querySelectorAll("dialog[open]")', self.script)
self.assertIn('renderTradeLog();\n closeTradeLogDialog();', self.script) self.assertIn('renderTradeLog();\n closeTradeLogDialog();', self.script)
def test_review_workspace_exposes_complete_watchlist_and_three_part_journal(self): def test_review_workspace_exposes_complete_watchlist_and_three_part_journal(self):