From c30d2107b3e93e0a3fb471dd34daad0a70972847 Mon Sep 17 00:00:00 2001 From: leefer Date: Tue, 28 Jul 2026 23:34:10 +0800 Subject: [PATCH] chore: complete stage 19 regression hardening --- STAGE19_ACCEPTANCE.md | 48 ++++++++++++ static/app.js | 36 +++++---- static/design-system.css | 4 - static/wentian-v2.css | 2 +- tests/e2e/app-shell.spec.js | 127 ++++++++++++++++++++++++++------ tests/test_frontend_contract.py | 3 +- 6 files changed, 179 insertions(+), 41 deletions(-) create mode 100644 STAGE19_ACCEPTANCE.md diff --git a/STAGE19_ACCEPTANCE.md b/STAGE19_ACCEPTANCE.md new file mode 100644 index 0000000..919b51c --- /dev/null +++ b/STAGE19_ACCEPTANCE.md @@ -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. diff --git a/static/app.js b/static/app.js index afc3973..14edc7e 100644 --- a/static/app.js +++ b/static/app.js @@ -204,6 +204,14 @@ const elements = { 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 stockPreviewCache = new Map(); const STOCK_PREVIEW_DELAY = 380; @@ -3076,7 +3084,7 @@ function openWatchlistDialog(item = null) { document.querySelector("#watchlistSearchInput").value = ""; document.querySelector("#watchlistSearchResults").innerHTML = ""; syncWatchlistSelection(Boolean(item)); - if (!elements.watchlistDialog.open) elements.watchlistDialog.showModal(); + openModalDialog(elements.watchlistDialog); requestAnimationFrame(() => (item ? document.querySelector("#watchlistRemark") : document.querySelector("#watchlistSearchInput")).focus()); } @@ -3240,7 +3248,7 @@ function populateJournalForm() { function openTradeLogDialog() { resetTradeLogForm(); - if (!elements.tradeLogDialog.open) elements.tradeLogDialog.showModal(); + openModalDialog(elements.tradeLogDialog); requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus()); } @@ -3311,7 +3319,7 @@ function editTradeLog(id) { document.querySelector("#tradeLogExecution").value = item.execution || ""; setText("tradeLogDialogTitle", "编辑交易日志"); setText("saveTradeLog", "保存修改"); - if (!elements.tradeLogDialog.open) elements.tradeLogDialog.showModal(); + openModalDialog(elements.tradeLogDialog); requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus()); } @@ -4067,7 +4075,7 @@ function renderScreenerProgress() { function openStrategyDrawer(target = "editor") { const drawer = document.querySelector("#strategyDrawer"); - if (!drawer.open) drawer.showModal(); + openModalDialog(drawer); requestAnimationFrame(() => { const focusTarget = target === "library" ? document.querySelector("#strategyList .strategy-item.active") || document.querySelector("#strategyList .strategy-item") @@ -5889,7 +5897,7 @@ function openHeavenReading(mode, options = {}) { ? Boolean(options.loading) : false; renderHeavenReadingDialog(); - if (!elements.heavenReadingDialog.open) elements.heavenReadingDialog.showModal(); + openModalDialog(elements.heavenReadingDialog); requestAnimationFrame(() => { syncHeavenReadingAnimation(); document.querySelector("#closeHeavenReadingDialog").focus(); @@ -5901,7 +5909,7 @@ async function openHeavenHistory(mode) { state.heavenReadingTab = "history"; state.heavenReadingSelectedId = 0; renderHeavenReadingDialog(); - if (!elements.heavenReadingDialog.open) elements.heavenReadingDialog.showModal(); + openModalDialog(elements.heavenReadingDialog); await loadHeavenReadingHistory(mode); } @@ -7570,7 +7578,7 @@ async function loadAlerts(openDialog = false) { state.alerts = payload.items || []; state.alertUnreadCount = number(payload.unread_count); renderAlerts(); - if (openDialog && !elements.alertsDialog.open) elements.alertsDialog.showModal(); + if (openDialog) openModalDialog(elements.alertsDialog); } catch (error) { if (openDialog) showToast(error.message || "提醒加载失败"); } @@ -7580,7 +7588,7 @@ function openAlerts() { toggleHeaderCommandMenu(false); toggleAccountDropdown(false); document.querySelector("#alertDate").value ||= todayString(); - elements.alertsDialog.showModal(); + openModalDialog(elements.alertsDialog); loadAlerts(); } @@ -7688,7 +7696,7 @@ function renderAlerts() { async function openReviewAssistant() { toggleHeaderCommandMenu(false); toggleAccountDropdown(false); - if (!elements.assistantDialog.open) elements.assistantDialog.showModal(); + openModalDialog(elements.assistantDialog); updateAssistantControls(); if (!hasMemberAccess()) { document.querySelector("#closeAssistantDialog").focus(); @@ -7841,7 +7849,7 @@ function updateAssistantControls() { function openGlobalSearch() { if (!state.user) return; toggleHeaderCommandMenu(false); - if (!elements.globalSearchDialog.open) elements.globalSearchDialog.showModal(); + openModalDialog(elements.globalSearchDialog); requestAnimationFrame(() => { elements.globalSearchInput.focus(); elements.globalSearchInput.select(); @@ -7984,7 +7992,7 @@ async function openEntityDetail(item) { setText("entityDetailDate", "正在加载行情"); document.querySelector("#entityDetailChange").className = ""; document.querySelector("#entityDetailMetrics").innerHTML = '
正在加载交易数据
'; - if (!elements.entityDetailDialog.open) elements.entityDetailDialog.showModal(); + openModalDialog(elements.entityDetailDialog); clearEntityDetailChart("正在加载日 K 数据"); try { 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("#stockNotes").innerHTML = '
正在加载笔记
'; updateWatchButton(); - if (!elements.stockDialog.open) elements.stockDialog.showModal(); + openModalDialog(elements.stockDialog); clearPriceChart("正在加载日 K 数据"); try { const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); @@ -8508,7 +8516,7 @@ async function openSettings(panel = "profile") { const status = document.querySelector("#connectionStatus"); status.className = "connection-status"; status.textContent = "正在读取账号状态"; - if (!elements.settingsDialog.open) elements.settingsDialog.showModal(); + openModalDialog(elements.settingsDialog); try { const payload = await apiRequest("/api/account/status"); const access = payload.llm_access || {}; @@ -8587,7 +8595,7 @@ async function switchAccount() { async function openAdminSettings(refreshOnly = false) { if (state.user?.role !== "admin") return; - if (!refreshOnly && !elements.adminDialog.open) elements.adminDialog.showModal(); + if (!refreshOnly) openModalDialog(elements.adminDialog); const status = document.querySelector("#adminConnectionStatus"); status.textContent = "正在读取系统状态"; try { diff --git a/static/design-system.css b/static/design-system.css index 95353da..40348fc 100644 --- a/static/design-system.css +++ b/static/design-system.css @@ -30,8 +30,6 @@ --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)); --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; --primary-share:1.45fr; --secondary-share:.75fr; @@ -912,8 +910,6 @@ tbody tr.clickable{cursor:pointer} } @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-current-phase-badge{padding:8px 12px} #sentimentCycleView .sentiment-phase-advice{margin-top:4px;padding:4px 8px;line-height:1.4} diff --git a/static/wentian-v2.css b/static/wentian-v2.css index e1fab63..f02175d 100644 --- a/static/wentian-v2.css +++ b/static/wentian-v2.css @@ -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 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.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; } @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); } } diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 558ccaf..a8dbef6 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -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/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") { payload = { 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(); }); +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 }) => { const options = { dashboardDelay: 350 }; 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("#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").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").nth(2)).toContainText("50.0%"); 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 }, ]; renderRotationHistory(); - renderRotationTable(state.dashboard.sector_rotation, state.dashboard.sectors); + renderRotationMembers(); }); 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 expect(page.locator("#rotationTracker")).toBeVisible(); 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 expect(page.locator("#rotationTracker")).toBeHidden(); - - 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 expect(page.locator("#rotationMembersEmpty")).toContainText("点击上方任意板块查看成分股"); await page.locator('[data-rotation-order="latest"]').click(); await expect(page.locator("#rotationHistory .rotation-day").first()).toContainText("07-24"); 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 page.locator('[data-view="screenerView"]').first().click(); 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 page.locator("#assistantButton").click(); 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.height = 80; const context = canvas.getContext("2d"); - context.fillStyle = CHART_BACKGROUND; + context.fillStyle = currentChartPalette().background; context.fillRect(0, 0, 40, 80); const priceY = (value) => 90 - value * 8; 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(() => ({ remaining: state.heartBreathingEndsAt - Date.now(), - incenseDuration: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration, - incenseDelay: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDelay, - rippleDuration: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDuration, - rippleDelay: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDelay, + incenseNames: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationName, + incenseDurations: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration, + incenseDelays: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDelay, 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).toBeLessThanOrEqual(46_000); - expect(timing.incenseDuration).toBe("45s"); - expect(timing.incenseDelay).toBe("1s"); - expect(timing.rippleDuration).toBe("9s"); - expect(timing.rippleDelay).toBe("1s"); - expect(timing.rippleAnimation).toContain("heart-breath-ripple"); - expect(timing.rippleKeyframes.at(-1).transform).toContain("0.62"); + expect(timing.incenseNames).toContain("heart-incense-burn"); + expect(timing.incenseNames).toContain("heart-incense-glow"); + expect(timing.incenseDurations).toContain("45s"); + expect(timing.incenseDelays).toContain("1s"); + expect(timing.rippleAnimation).toBe("none"); await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "prepare"); await expect(page.locator("#breathingPhase")).toHaveText("静"); 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("#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(() => { 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("#breathingPhase")).toHaveText("呼"); + await expect(page.locator(".heart-breath-ripple span").first()).toHaveCSS("transition-duration", "4s"); }); 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); expect(overflow).toBeLessThanOrEqual(1); 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 }) => { @@ -1701,6 +1785,7 @@ test("new review workflows render account-scoped records", async ({ page }) => { await page.locator('[data-view="screenerView"]').first().click(); 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 expect(page.locator("#strategyDrawer")).toBeVisible(); 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 page.locator('#mentorList [data-mentor-id="source-b"]').click(); 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("公开访谈与多源材料"); const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox(); const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox(); diff --git a/tests/test_frontend_contract.py b/tests/test_frontend_contract.py index dd8cfc5..e394256 100644 --- a/tests/test_frontend_contract.py +++ b/tests/test_frontend_contract.py @@ -296,7 +296,8 @@ class FrontendContractTests(unittest.TestCase): def test_trade_log_editor_is_dialog_based(self): self.assertIn('id="openTradeLogDialog"', 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) def test_review_workspace_exposes_complete_watchlist_and_three_part_journal(self):