Files
xiaobai-review/tests/test_frontend_contract.py
T
MS-01-Codexandmultica-agent 9f8fbc1937 fix: reconnect mentor page into the project shell
Remove the mentor immersive rules in shared/shell.css that hid the module
nav, header actions, market tape, overview strip and status bar while
mentorView was active, which made the page look like an independent site.
Mentor now stays inside the canonical 小白复盘 shell and owns its own
"问师 + 数据日期" title header inside #mentorView (B-92 content intact).

Also fix the shell's latent 1024px overflow: .main min-width 1080 now only
applies at >=1280, and the overview strip scrolls internally instead of
clipping its right side when it is wider than the content column.

Update the contract and e2e baselines to assert the shell stays visible
and usable on mentor, page switching leaves no residue, and add a
dedicated shell-integration e2e test.

Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 17:19:03 +08:00

440 lines
24 KiB
Python

from __future__ import annotations
import re
import subprocess
import unittest
from html.parser import HTMLParser
from pathlib import Path
from tests.frontend_test_helpers import (
assembled_frontend_runtime,
assembled_frontend_document,
registered_frontend_runtime_scripts,
)
STATIC_DIR = Path(__file__).resolve().parents[1] / "frontend"
def uncommitted_runtime_files() -> set[str]:
"""Relative frontend paths with pending working-tree edits.
The id-reference contract is validated against committed sources only.
Parallel agents may temporarily reference ids that their own (uncommitted)
HTML change reintroduces; those are resolved by the owning commit.
"""
result = subprocess.run(
["git", "-C", str(STATIC_DIR.parent), "diff", "--name-only", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return set()
return {
line.strip().removeprefix("app/frontend/")
for line in result.stdout.splitlines()
if line.strip().startswith("app/frontend/")
}
class IdCollector(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.ids: list[str] = []
def handle_starttag(self, tag, attrs):
self.ids.extend(value for key, value in attrs if key == "id" and value)
class FrontendContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.html = assembled_frontend_document()
cls.registry = (STATIC_DIR / "pages.config.js").read_text(encoding="utf-8")
cls.script = assembled_frontend_runtime()
cls.shell = (STATIC_DIR / "shared" / "shell.js").read_text(encoding="utf-8")
cls.ui_core = (STATIC_DIR / "shared" / "ui-core.js").read_text(encoding="utf-8")
cls.tokens = (STATIC_DIR / "shared" / "tokens.css").read_text(encoding="utf-8")
cls.auth_styles = (STATIC_DIR / "shared" / "auth.css").read_text(encoding="utf-8")
cls.feedback_styles = (STATIC_DIR / "shared" / "components" / "feedback.css").read_text(encoding="utf-8")
cls.sentiment_styles = (STATIC_DIR / "pages" / "sentiment" / "foundation.css").read_text(encoding="utf-8")
cls.screener_styles = (STATIC_DIR / "pages" / "screener" / "foundation.css").read_text(encoding="utf-8")
cls.mentor_styles = (STATIC_DIR / "pages" / "mentor" / "foundation.css").read_text(encoding="utf-8")
cls.heaven_styles = (STATIC_DIR / "pages" / "heaven" / "foundation.css").read_text(encoding="utf-8")
cls.review_styles = (STATIC_DIR / "pages" / "review" / "foundation.css").read_text(encoding="utf-8")
cls.theme_library_styles = (STATIC_DIR / "pages" / "themes" / "foundation.css").read_text(encoding="utf-8")
cls.base_styles = (STATIC_DIR / "shared" / "base.css").read_text(encoding="utf-8")
collector = IdCollector()
collector.feed(cls.html)
cls.ids = collector.ids
def test_html_ids_are_unique(self):
duplicates = sorted({item for item in self.ids if self.ids.count(item) > 1})
self.assertEqual(duplicates, [])
def test_literal_id_selectors_exist_in_html(self):
pending = uncommitted_runtime_files()
dangling: list[str] = []
for url in registered_frontend_runtime_scripts():
relative = url.split("?", 1)[0].lstrip("/")
if relative.startswith("vendor/"):
continue
if relative in pending:
continue
source = (STATIC_DIR / relative).read_text(encoding="utf-8")
selectors = set(re.findall(r'querySelector\("#([A-Za-z][A-Za-z0-9_-]*)"\)', source))
selectors.update(re.findall(r'getElementById\("([A-Za-z][A-Za-z0-9_-]*)"\)', source))
selectors.update(re.findall(r'setText\("([A-Za-z][A-Za-z0-9_-]*)"', source))
dangling.extend(
f"{relative}:{selector}"
for selector in sorted(selectors - set(self.ids))
)
self.assertEqual(dangling, [])
def test_all_primary_views_have_navigation_entries(self):
views = set(re.findall(r'id="([A-Za-z][A-Za-z0-9_-]*View|limitPool)" class="workspace-view', self.html))
internal_views = set(re.findall(
r'<section id="([A-Za-z][A-Za-z0-9_-]*View)" class="workspace-view[^"]*"[^>]*\bdata-internal-view\b',
self.html,
))
navigation = set(re.findall(r'data-view="([A-Za-z][A-Za-z0-9_-]*)"', self.html))
self.assertEqual(views - internal_views, navigation)
self.assertEqual(len(views - internal_views), 16)
self.assertEqual(internal_views, {"screenerTrackingView"})
def test_market_discovery_views_are_wired_end_to_end(self):
for view_id in ("auctionView", "themeLibraryView", "popularityView"):
self.assertIn(f'id="{view_id}"', self.html)
self.assertIn(f'data-view="{view_id}"', self.html)
for endpoint in ("/api/auction?", "/api/themes?", "/api/themes/detail?", "/api/popularity?"):
self.assertIn(endpoint, self.script)
screener_sources = "\n".join(
(
STATIC_DIR.parent
/ "backend"
/ "features"
/ "screener"
/ filename
).read_text(encoding="utf-8")
for filename in ("catalog.py", "factors.py")
)
for field in (
"auction_change", "auction_amount_million",
"auction_turnover_rate", "auction_volume_ratio",
):
self.assertIn(field, screener_sources)
def test_wencai_workspace_is_not_exposed_and_mentor_hides_internal_quality_score(self):
self.assertNotIn('id="wencaiView"', self.html)
self.assertNotIn('data-view="wencaiView"', self.html)
for endpoint in ("/api/wencai", "/api/wencai/query", "/api/wencai/saved"):
self.assertNotIn(endpoint, self.script)
self.assertNotIn("${score}/${total}", self.script)
def test_auction_navigation_and_frontend_pools_follow_product_order(self):
rotation = self.html.index('data-view="rotationView"')
auction = self.html.index('data-view="auctionView"')
themes = self.html.index('data-view="themeLibraryView"')
self.assertLess(rotation, auction)
self.assertLess(auction, themes)
for dataset in ("focus", "watchlist", "all", "onePrice"):
self.assertIn(f'data-auction-dataset="{dataset}"', self.html)
dataset_positions = [self.html.index(f'data-auction-dataset="{dataset}"') for dataset in ("focus", "watchlist", "all", "onePrice")]
self.assertEqual(dataset_positions, sorted(dataset_positions))
for filter_name in ("all", "above", "matched", "below"):
self.assertIn(f'data-auction-filter="{filter_name}"', self.html)
self.assertNotIn('data-auction-filter="strong"', self.html)
self.assertNotIn('data-auction-filter="limit"', self.html)
self.assertIn('id="auctionThemeCarry"', self.html)
self.assertIn('id="auctionAmountTrend"', self.html)
self.assertNotIn('id="auctionNewsTitle"', self.html)
self.assertIn('id="auctionWorkspaceTitle"', self.html)
self.assertIn('id="auctionExpectationFilterbar"', self.html)
self.assertIn('id="auctionExpectationControls"', self.html)
self.assertNotIn('id="auctionAboveCount"', self.html)
self.assertNotIn('id="auctionMatchedCount"', self.html)
self.assertNotIn('id="auctionBelowCount"', self.html)
self.assertNotIn('class="auction-news-entry"', self.html)
def test_visual_renovation_keeps_required_product_controls(self):
for order in ("oldest", "latest"):
self.assertIn(f'data-rotation-order="{order}"', self.html)
self.assertIn('id="dragonProfilesButton"', self.html)
self.assertIn('id="sentimentHistoryBody"', self.html)
self.assertIn('id="sentimentPreviousPositive"', self.html)
self.assertIn('id="accountDropdown"', self.html)
self.assertIn('id="settingsButton"', self.html)
def test_screener_uses_progressive_strategy_editor(self):
for step in ("regime", "strategy", "run", "result"):
self.assertIn(f'data-screener-step="{step}"', self.html)
def test_screener_exposes_curated_and_quant_workspaces(self):
for mode in ("smart", "curated", "quant"):
self.assertIn(f'data-screener-mode="{mode}"', self.html)
self.assertIn(f'data-screener-panel="{mode}"', self.html)
for element_id in (
"curatedStrategyList", "quantFilterRows",
"quantScoreRows", "quantRunButton", "quantSaveButton",
):
self.assertIn(f'id="{element_id}"', self.html)
for removed_id in (
"curatedRunButton", "factorSyncButton", "screenerRunButton",
"changeStrategyButton",
):
self.assertNotIn(f'id="{removed_id}"', self.html)
self.assertIn("盘后自动候选池", self.html)
self.assertIn("自定义选股", self.html)
self.assertIn('id="strategyDrawer" class="strategy-drawer"', self.html)
self.assertIn('id="openStrategyDrawerButton"', self.html)
self.assertIn('id="closeStrategyDrawerButton"', self.html)
self.assertIn('id="activeStrategyDescription"', self.html)
self.assertIn('openStrategyDrawer("editor")', self.script)
for element_id in ("curatedSuitableEnvironment", "curatedFailureRisk"):
self.assertIn(f'id="{element_id}"', self.html)
self.assertIn("meta.suitable_environment", self.script)
self.assertIn("meta.failure_risk", self.script)
self.assertIn('mode === "curated" ? "暂无符合条件个股"', self.script)
def test_curated_library_explains_empty_signals_and_supports_school_views(self):
for element_id in ("curatedSchoolFilters", "curatedStrategyList"):
self.assertIn(f'id="{element_id}"', self.html)
for view in ("list", "grid"):
self.assertIn(f'data-curated-view="{view}"', self.html)
for school in ("基本面", "趋势", "短线", "动量"):
self.assertIn(school, self.script)
self.assertIn("curatedStrategyRunState", self.script)
self.assertIn("必需数据已完整,本日没有股票同时满足", self.script)
def test_dialogs_and_dark_table_hover_have_shared_safety_constraints(self):
self.assertIn(".settings-dialog:not(.heaven-reading-dialog)[open] {", self.auth_styles)
self.assertIn("margin: auto;", self.auth_styles)
self.assertIn("max-height: min(760px, -28px + 100dvh);", self.auth_styles)
self.assertIn('#reviewWorkspaceView .data-table tbody tr:hover td', self.review_styles)
self.assertIn('#reviewWorkspaceView .data-table tbody td', self.review_styles)
self.assertIn('#screenerView .screener-result-frame tbody tr:hover td:last-child', self.screener_styles)
def test_global_toast_has_one_owner_and_cannot_stretch_between_insets(self):
self.assertIn("#toast.toast {", self.feedback_styles)
self.assertIn("inset: auto 18px 48px auto;", self.feedback_styles)
self.assertIn("width: max-content;", self.feedback_styles)
self.assertIn("height: auto;", self.feedback_styles)
self.assertIn("#toast.toast[hidden] {", self.feedback_styles)
self.assertIn("display: none;", self.feedback_styles)
self.assertNotRegex(self.heaven_styles, r"(?m)^\.toast\s*\{")
def test_public_knowledge_editors_are_hidden_for_non_admins(self):
self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script)
self.assertIn('document.querySelector("#sectorPhaseManager").hidden = !isAdmin;', self.script)
self.assertIn('const canManage = state.user?.role === "admin";', self.script)
def test_shared_ui_core_loads_before_application(self):
self.assertLess(
self.registry.index('"/shared/ui-core.js"'),
self.registry.index('"/app.js'),
)
for function_name in (
"number", "clamp", "escapeHtml", "formatNumber", "formatTimestamp",
"displayCompactDate", "todayString", "localDateString", "parseLocalDate",
):
self.assertIn(f"function {function_name}(", self.ui_core)
def test_business_views_do_not_expose_engineering_source_labels(self):
prohibited = (
"Tushare 实时行情",
"Tushare 日K",
"SQLite 缓存",
"演示日K",
"rt_k 实时截面",
)
for label in prohibited:
self.assertNotIn(label, self.script)
def test_stock_hover_preview_always_uses_latest_market_context(self):
start = self.script.index("async function showStockPreview")
end = self.script.index("async function showEntityPreview", start)
preview_loader = self.script[start:end]
self.assertIn('const cacheKey = `${code}:latest`;', preview_loader)
self.assertIn('/preview`', preview_loader)
self.assertNotIn("trade_date", preview_loader)
self.assertNotIn("elements.tradeDate.value", preview_loader)
def test_daily_rising_candles_are_fully_hollow_without_crossing_wicks(self):
start = self.script.index("function drawCandlestick")
end = self.script.index("function drawPriceChart", start)
candle = self.script[start:end]
self.assertIn("context.lineTo(x, bodyTop);", candle)
self.assertIn("context.moveTo(x, bodyBottom);", candle)
self.assertIn("context.lineTo(x, lowY);", candle)
self.assertIn("context.fillStyle = palette.background;", candle)
self.assertIn("context.strokeRect(bodyLeft, bodyTop, candleWidth, bodyHeight);", candle)
self.assertNotIn("context.lineTo(x, lowY);\n context.stroke();\n const openY", candle)
def test_stock_hover_intraday_draws_average_without_source_label(self):
start = self.script.index("function drawIntradayCanvas")
end = self.script.index("function drawDailyPreviewChart", start)
chart = self.script[start:end]
self.assertIn("point.average", chart)
self.assertIn("context.strokeStyle = palette.average;", chart)
self.assertIn('intraday_trade_date || payload.meta?.trade_date', self.script)
self.assertIn('(payload.intraday || []).length ? "最新分时 · 1分钟"', self.script)
def test_hover_prefers_daily_and_intraday_uses_centered_zero_axis(self):
self.assertIn('stockPreviewChart: "daily"', self.script)
self.assertIn('state.stockPreviewChart = "daily";', self.script)
self.assertIn('selectStockPreviewChart("daily");', self.script)
start = self.script.index("function drawIntradayCanvas")
end = self.script.index("function drawIntradayPreviewChart", start)
intraday = self.script[start:end]
self.assertIn("Math.abs(maximum - previousClose)", intraday)
self.assertIn("Math.abs(previousClose - minimum)", intraday)
self.assertIn('context.fillText("0.00%"', intraday)
self.assertIn('label: "09:30"', intraday)
self.assertIn('label: "11:30 / 13:00"', intraday)
self.assertIn('label: "15:00"', intraday)
self.assertIn("intradayMinuteOffset(points[index]?.time) / 240", intraday)
def test_detail_dialogs_offer_lazy_daily_and_intraday_modes(self):
self.assertIn('data-stock-detail-chart="daily"', self.html)
self.assertIn('data-stock-detail-chart="intraday"', self.html)
self.assertIn('data-entity-detail-chart="daily"', self.html)
self.assertIn('data-entity-detail-chart="intraday"', self.html)
self.assertIn('/api/chart/intraday?', self.script)
self.assertIn('drawIntradayCanvas(elements.priceChart', self.script)
self.assertIn('drawIntradayCanvas(elements.entityDetailChart', self.script)
self.assertIn('state.stockDetailChartMode === "intraday"', self.script)
self.assertIn('state.entityDetailChartMode === "intraday"', self.script)
def test_entity_daily_chart_uses_runtime_theme_palette(self):
start = self.script.index("function drawEntityDetailChart")
end = self.script.index("function clearEntityDetailChart", start)
chart = self.script[start:end]
self.assertIn("const palette = currentChartPalette();", chart)
self.assertIn("context.fillStyle = palette.background;", chart)
self.assertIn("context.fillStyle = palette.axis;", chart)
self.assertNotIn('context.fillStyle = "#6c7983";', chart)
def test_dark_mentor_tokens_and_sentiment_bottom_clearance_are_defined(self):
self.assertIn(":root[data-theme=\"dark\"] #mentorView.workspace-view {", self.mentor_styles)
self.assertIn("--qp-bg-chat: #232529;", self.mentor_styles)
self.assertIn("--qp-bg-bubble-self: #35598C;", self.mentor_styles)
self.assertIn("--qp-bg-selected: #2B3B58;", self.mentor_styles)
self.assertIn("--qp-link: #316FEF;", self.mentor_styles)
self.assertIn("--qp-accent-soft: #5B8DEF;", self.mentor_styles)
self.assertIn("--mentor-directory-width: 300px;", self.tokens)
self.assertIn("--mentor-composer-min-height: 85px;", self.tokens)
self.assertIn("#mentorView .mentor-message.user .mentor-message-content {", self.mentor_styles)
self.assertIn("background: var(--qp-bg-bubble-self);", self.mentor_styles)
self.assertIn("#mentorView .mentor-quick-prompts button {", self.mentor_styles)
self.assertIn("#mentorView .mentor-composer-hint {", self.mentor_styles)
self.assertIn("#sentimentCycleView .sentiment-history-frame {", self.sentiment_styles)
self.assertIn("margin-bottom: var(--card-gap);", self.sentiment_styles)
self.assertIn("padding-bottom: var(--card-gap);", self.sentiment_styles)
self.assertIn("--sentiment-history-max-height: 510px;", self.tokens)
self.assertIn("max-height: var(--sentiment-history-max-height);", self.sentiment_styles)
self.assertIn("overflow: auto;", 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")
self.assertNotIn('body[data-active-view="mentorView"]', shell_styles)
self.assertNotIn("body[data-active-view=mentorView]", shell_styles)
self.assertIn('id="mentorPageSubtitle"', mentor_html)
self.assertIn('class="mentor-page-header"', mentor_html)
self.assertIn("#mentorView .mentor-page-header", self.mentor_styles)
for tone in ("violet", "blue", "green", "orange", "red", "teal", "purple", "yellow"):
self.assertIn(f".mentor-avatar-tone-{tone} {{", self.mentor_styles, tone)
self.assertIn(
f':root[data-theme="dark"] #mentorView .mentor-avatar-tone-{tone} {{',
self.mentor_styles,
tone,
)
self.assertIn("kobe92-perspective", self.script)
self.assertIn('"beijingchaojia-perspective": "green"', self.script)
self.assertIn("`mentor-avatar-tone-${mentorAvatarTone(mentor)}`", self.script)
self.assertIn('${escapeHtml(grade)}级', self.script)
self.assertIn("置顶", self.script)
self.assertNotIn("activeMentorBadges", self.script)
self.assertNotIn("activeMentorBadges", mentor_html)
self.assertIn("#mentorView .mentor-message.assistant .mentor-message-body {", self.mentor_styles)
self.assertIn("max-width: 900px;", self.mentor_styles)
self.assertIn("#mentorView .mentor-follow-ups {", self.mentor_styles)
self.assertIn("width: 382px;", self.mentor_styles)
self.assertIn("data-lucide=\"filter\"", mentor_html)
self.assertNotIn("chevron-down", mentor_html)
self.assertNotIn("· 回答完成", self.script)
self.assertIn('state.mentorLoading ? "正在生成回答..." : (selected?.tagline', self.script)
def test_theme_switch_is_atomic_and_theme_library_loading_surface_is_dark_safe(self):
self.assertIn('typeof document.startViewTransition === "function"', self.script)
self.assertIn('root.classList.add("theme-switching")', self.script)
self.assertIn('root.classList.remove("theme-switching")', self.script)
self.assertIn("clearThemeTransitionEffects();", self.script)
self.assertIn("redrawThemeSensitiveVisuals();", self.script)
self.assertIn(":root.theme-switching *", self.theme_library_styles)
self.assertIn("::view-transition-old(root)", self.base_styles)
self.assertIn(".theme-detail-empty-v2", self.theme_library_styles)
def test_membership_copy_includes_review_assistant_access(self):
self.assertIn("复盘助手仅对会员开放", self.html)
self.assertIn("智能选股、问师、问天、复盘助手等智能功能", self.html)
self.assertIn("自选股、复盘记录与交易日志", self.html)
self.assertIn("每日智能分析额度", self.html)
def test_review_assistant_uses_the_same_member_gate_pattern(self):
self.assertIn('id="assistantMemberGate" class="member-gate assistant-member-gate"', self.html)
self.assertIn('id="assistantMemberContent" class="assistant-member-content"', self.html)
self.assertIn('elements.assistantDialog.classList.toggle("member-locked", !unlocked);', self.script)
self.assertIn('button.disabled = !unlocked || state.assistantLoading;', self.script)
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('openModalDialog(elements.tradeLogDialog)', self.script)
self.assertIn('document.querySelectorAll("dialog[open]")', self.shell)
self.assertIn('renderTradeLog();\n closeTradeLogDialog();', self.script)
def test_review_workspace_exposes_complete_watchlist_and_three_part_journal(self):
for label in (
"今日涨幅", "5日涨幅", "竞价关注(分)", "跟踪备注", "添加自选",
"今日盘面一句话", "今日做对了什么 / 做错了什么", "明日策略",
):
self.assertIn(label, self.html)
for element_id in (
"watchlistDialog", "watchlistSearchInput", "watchlistRemark",
"journalSummary", "journalContent", "journalPlan",
):
self.assertIn(f'id="{element_id}"', self.html)
self.assertIn('summary: document.querySelector("#journalSummary").value', self.script)
self.assertIn('return_5d', self.script)
self.assertIn('attention_score', self.script)
def test_heaven_interpretations_use_one_dialog_and_history_tabs(self):
self.assertIn('id="heavenReadingDialog"', self.html)
self.assertIn('data-heaven-reading-tab="current"', self.html)
self.assertIn('data-heaven-reading-tab="history"', self.html)
for button_id in ("historyTrendButton", "historyFortuneButton", "historyHeartButton"):
self.assertIn(f'id="{button_id}"', self.html)
self.assertIn('state.heavenInterpretations.fortune = payload.daily_fortune_reading || "";', self.script)
self.assertIn('if (existing) {\n openHeavenReading(mode, { loading: false });', self.script)
def test_heart_question_presets_lock_before_casting_and_reach_backend(self):
for preset, label in (("trade", "交易"), ("mind", "心境"), ("unthemed", "无题")):
self.assertIn(f'data-heart-question-preset="{preset}"', self.html)
self.assertIn(f'>{label}</button>', self.html)
self.assertIn('id="heartQuestionInput"', self.html)
self.assertIn("Promise.all([climateSequence(), qiSequence(), personalSequence()])", self.script)
self.assertIn("function formatHeavenAnswer(content)", self.script)
self.assertIn('data-lucide="${phaseIcon(dayMasterElement)}"', self.script)
self.assertIn('function phaseIcon(element)', self.script)
self.assertIn('setHeartQuestionLocked(true);', self.script)
self.assertIn('payload.question = state.heartQuestion;', self.script)
self.assertIn('payload.question_preset = state.heartQuestionPreset;', self.script)
self.assertIn('payload.cast_at = state.heartCastAt;', self.script)
if __name__ == "__main__":
unittest.main()